diff --git a/openquake/calculators/country_impact_report_builder.py b/openquake/calculators/country_impact_report_builder.py
index 1487ac207f3..4913de91aee 100644
--- a/openquake/calculators/country_impact_report_builder.py
+++ b/openquake/calculators/country_impact_report_builder.py
@@ -569,7 +569,7 @@ def _build_left_bundle(self, summary_table, body_left_style, title_style):
exposed_value_txt = (
f'The exposed value refers to the assets and population located'
- f' within a {self.maximum_distance}km radius of the epicentre.')
+ f' within a {self.maximum_distance} km radius of the epicentre.')
left_bundle.append(
self.Paragraph(exposed_value_txt, body_left_style))
if self.no_uncertainty:
diff --git a/openquake/server/authorization/__init__.py b/openquake/server/authorization/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/openquake/server/authorization/apps.py b/openquake/server/authorization/apps.py
new file mode 100644
index 00000000000..3abb59786e7
--- /dev/null
+++ b/openquake/server/authorization/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class AuthorizationConfig(AppConfig):
+ name = 'openquake.server.authorization'
diff --git a/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py b/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py
new file mode 100644
index 00000000000..145861e04b2
--- /dev/null
+++ b/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py
@@ -0,0 +1,33 @@
+from django.db import migrations
+
+
+def create_permission(apps, schema_editor):
+ Permission = apps.get_model('auth', 'Permission')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ content_type = ContentType.objects.get_for_model(Permission)
+ Permission.objects.get_or_create(
+ codename='can_share_own_jobs',
+ name='Can share own jobs',
+ content_type=content_type,
+ )
+
+
+def delete_permission(apps, schema_editor):
+ Permission = apps.get_model('auth', 'Permission')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ content_type = ContentType.objects.get_for_model(Permission)
+ Permission.objects.filter(
+ codename='can_share_own_jobs', content_type=content_type).delete()
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_permission, delete_permission),
+ ]
diff --git a/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py b/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py
new file mode 100644
index 00000000000..07d8b3ee135
--- /dev/null
+++ b/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py
@@ -0,0 +1,24 @@
+from django.db import migrations
+
+
+def create_permission(apps, schema_editor):
+ Permission = apps.get_model('auth', 'Permission')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ content_type = ContentType.objects.get_for_model(Permission)
+ Permission.objects.get_or_create(
+ codename='can_view_exposure',
+ name='Can view exposure',
+ content_type=content_type,
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('authorization', '0001_create_can_share_own_jobs_permission'),
+ ]
+
+ # The legacy UserProfile migration also creates this permission; don't
+ # remove it when rolling back this migration.
+ operations = [
+ migrations.RunPython(create_permission, migrations.RunPython.noop),
+ ]
diff --git a/openquake/server/authorization/migrations/__init__.py b/openquake/server/authorization/migrations/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/openquake/server/settings.py b/openquake/server/settings.py
index 735457802cd..bad3a1ed3cf 100644
--- a/openquake/server/settings.py
+++ b/openquake/server/settings.py
@@ -339,6 +339,7 @@ def filter(self, record):
TEMPLATES[0]['OPTIONS']['context_processors'] = CONTEXT_PROCESSORS
# add installed_apps for cookie-consent
for app in ('django.contrib.auth', 'django.contrib.contenttypes',
+ 'openquake.server.authorization',
'openquake.server.user_profile', 'cookie_consent',):
if app not in INSTALLED_APPS:
INSTALLED_APPS += (app,)
diff --git a/openquake/server/templates/engine/index.html b/openquake/server/templates/engine/index.html
index b1ae13d206e..a27557ad977 100644
--- a/openquake/server/templates/engine/index.html
+++ b/openquake/server/templates/engine/index.html
@@ -93,11 +93,13 @@
List of calculations{% if application_mode != 'AELO' %}{% if server_name %}
{% endif %}
<% if (calc.get('status') == 'complete' || calc.get('status') == 'shared') { %>
- {% if application_mode != 'READ_ONLY' and user_level >= 2 and lockdown %}
- <% if (calc.get('status') == 'complete') { %>
- Share
- <% } else if (calc.get('status') == 'shared') { %>
- Unshare
+ {% if application_mode != 'READ_ONLY' and lockdown %}
+ <% if (calc.get('can_manage_sharing')) { %>
+ <% if (calc.get('status') == 'complete') { %>
+ Share
+ <% } else if (calc.get('status') == 'shared') { %>
+ Unshare
+ <% } %>
<% } %>
{% endif %}
diff --git a/openquake/server/tests/test_restricted_mode.py b/openquake/server/tests/test_restricted_mode.py
index f17b619138f..4c1d811a585 100644
--- a/openquake/server/tests/test_restricted_mode.py
+++ b/openquake/server/tests/test_restricted_mode.py
@@ -176,6 +176,50 @@ def test_level_2_can_only_share_own_jobs(self):
{'status': 'complete'}, job.calc_id)
self.remove_calc(job.calc_id)
+ def test_group_permission_allows_low_level_users_to_share_own_jobs(self):
+ from django.contrib.auth.models import Group, Permission
+ from django.contrib.contenttypes.models import ContentType
+
+ content_type = ContentType.objects.get(
+ app_label='auth', model='permission')
+ permission, _ = Permission.objects.get_or_create(
+ codename='can_share_own_jobs', name='Can share own jobs',
+ content_type=content_type)
+ group, _ = Group.objects.get_or_create(
+ name='test-can-share-own-jobs')
+ group.permissions.add(permission)
+ self.user1.groups.add(group)
+
+ own_dic = dict(calculation_mode='event_based',
+ description='test_group_share_own_job')
+ other_dic = dict(calculation_mode='event_based',
+ description='test_group_share_other_job')
+ [own_job] = create_jobs([own_dic], user_name=self.user1.username)
+ [other_job] = create_jobs([other_dic], user_name=self.user2.username)
+ db("UPDATE job SET ?D WHERE id=?x",
+ {'status': 'complete', 'is_running': 0}, own_job.calc_id)
+ db("UPDATE job SET ?D WHERE id=?x",
+ {'status': 'shared', 'is_running': 0}, other_job.calc_id)
+
+ self.c.login(username=self.user1.username, password=self.password1)
+ ret = self.post(f'{own_job.calc_id}/share')
+ self.assertEqual(ret.status_code, 200)
+ ret = self.post(f'{own_job.calc_id}/unshare')
+ self.assertEqual(ret.status_code, 200)
+
+ ret = self.post(f'{other_job.calc_id}/share')
+ self.assertEqual(ret.status_code, 403)
+ self.assertEqual(db('SELECT status FROM job WHERE id=?x',
+ other_job.calc_id)[0].status, 'shared')
+ ret = self.get('list', include_shared=1)
+ listed = {calc['id']: calc for calc in ret.json()}
+ self.assertTrue(listed[own_job.calc_id]['can_manage_sharing'])
+ self.assertFalse(listed[other_job.calc_id]['can_manage_sharing'])
+
+ self.remove_calc(own_job.calc_id)
+ self.c.login(username=self.user2.username, password=self.password2)
+ self.remove_calc(other_job.calc_id)
+
def test_superuser_can_share_other_users_jobs(self):
job_dic = dict(calculation_mode='event_based',
description='test_superuser_share_job')
diff --git a/openquake/server/views.py b/openquake/server/views.py
index b932b167184..36ca6ae0ec5 100644
--- a/openquake/server/views.py
+++ b/openquake/server/views.py
@@ -553,16 +553,23 @@ def calc_list(request, id=None):
utils.get_valid_users(request),
not utils.is_superuser(request), id)
response_data = []
- username = psutil.Process(os.getpid()).username()
+ username = utils.get_username(request)
+ superuser = utils.is_superuser(request)
+ can_share_own_jobs = (
+ get_user_level(request) >= 2 or
+ request.user.has_perm('auth.can_share_own_jobs'))
+ process_username = psutil.Process(os.getpid()).username()
for (hc_id, owner, status, calculation_mode, is_running, desc, pid,
parent_id, size_mb, host, start_time, relevant, tags) in calc_data:
+ can_manage_sharing = superuser or (
+ owner == username and can_share_own_jobs)
if settings.DISPLAY_USER_HOST and host:
owner += '@' + host.split('.')[0]
url = urljoin(base_url, 'v1/calc/%d' % hc_id)
abortable = False
if is_running:
try:
- if psutil.Process(pid).username() == username:
+ if psutil.Process(pid).username() == process_username:
abortable = True
except psutil.NoSuchProcess:
pass
@@ -573,6 +580,7 @@ def calc_list(request, id=None):
dict(id=hc_id, owner=owner,
calculation_mode=calculation_mode, status=status,
is_running=bool(is_running), description=desc, url=url,
+ can_manage_sharing=can_manage_sharing,
parent_id=parent_id, abortable=abortable, size_mb=size_mb,
start_time=start_time_str, relevant=relevant, tags=tags))
@@ -627,7 +635,8 @@ def calc_remove(request, calc_id):
def share_job(request, calc_id, share):
user_level = get_user_level(request)
superuser = utils.is_superuser(request)
- if user_level < 2 and not superuser:
+ if (user_level < 2 and not superuser and
+ not request.user.has_perm('auth.can_share_own_jobs')):
return HttpResponseForbidden()
try:
job = logs.dbcmd('get_job', calc_id)