diff --git a/.env b/.env index 0fc6dce..11a0ba3 100644 --- a/.env +++ b/.env @@ -78,7 +78,7 @@ NGINX_PORT=80 NGINX_SSLPORT=443 # Extensions -CKAN__PLUGINS="envvars image_view text_view recline_view datastore datapusher d4science_theme dcat dcat_rdf_harvester dcat_json_harvester dcat_json_interface structured_data harvest ckan_harvester spatial_metadata spatial_query" +CKAN__PLUGINS="envvars image_view text_view recline_view datastore datapusher d4science_theme activity dcat dcat_rdf_harvester dcat_json_harvester dcat_json_interface structured_data harvest ckan_harvester spatial_metadata spatial_query" CKAN__HARVEST__MQ__TYPE=redis CKAN__HARVEST__MQ__HOSTNAME=redis CKAN__HARVEST__MQ__PORT=6379 diff --git a/.gitignore b/.gitignore index 06c7c30..2da5ce3 100755 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ _service-provider/* _solr/schema.xml _src/* local/* +.vscode/settings.json diff --git a/src/ckanext-d4science/ckanext/d4science/plugin.py b/src/ckanext-d4science/ckanext/d4science/plugin.py index ad2d7a6..5e016b8 100644 --- a/src/ckanext-d4science/ckanext/d4science/plugin.py +++ b/src/ckanext-d4science/ckanext/d4science/plugin.py @@ -13,12 +13,11 @@ import ckan.plugins.toolkit as toolkit class D4SciencePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) - # plugins.implements(plugins.IAuthFunctions) - # plugins.implements(plugins.IActions) - # plugins.implements(plugins.IBlueprint) + plugins.implements(plugins.IAuthFunctions) + plugins.implements(plugins.IActions) # plugins.implements(plugins.IClick) - # plugins.implements(plugins.ITemplateHelpers) - # plugins.implements(plugins.IValidators) + plugins.implements(plugins.ITemplateHelpers) + plugins.implements(plugins.IValidators) #ckan 2.10 plugins.implements(plugins.IBlueprint) @@ -50,8 +49,7 @@ class D4SciencePlugin(plugins.SingletonPlugin): def get_blueprint(self): blueprint = Blueprint('foo', self.__module__) rules = [ - ('/foo', 'custom_action', custom_action), - ('/group', 'group_index', custom_group_index), + ('/group', 'group_index', custom_group_index), ] for rule in rules: blueprint.add_url_rule(*rule) diff --git a/src/ckanext-d4science_theme/ckanext/__pycache__/__init__.cpython-310.pyc b/src/ckanext-d4science_theme/ckanext/__pycache__/__init__.cpython-310.pyc index 1573eef..9ce4900 100644 Binary files a/src/ckanext-d4science_theme/ckanext/__pycache__/__init__.cpython-310.pyc and b/src/ckanext-d4science_theme/ckanext/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/.gitignore b/src/ckanext-d4science_theme/ckanext/activity/__init__.py similarity index 100% rename from src/ckanext-d4science_theme/ckanext/d4science_theme/templates/.gitignore rename to src/ckanext-d4science_theme/ckanext/activity/__init__.py diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/activity-stream.js b/src/ckanext-d4science_theme/ckanext/activity/assets/activity-stream.js new file mode 100644 index 0000000..69874ba --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/activity-stream.js @@ -0,0 +1,34 @@ +/* Activity stream + * Handle the pagination for activity list + * + * Options + * - page: current page number + */ +this.ckan.module('activity-stream', function($) { + return { + + /* Initialises the module setting up elements and event listeners. + * + * Returns nothing. + */ + initialize: function () { + $('#activity_types_filter_select').on( + 'change', + this._onChangeActivityType + ); + }, + + + /* Filter using the selected + * activity type + * + * Returns nothing + */ + _onChangeActivityType: function (event) { + // event.preventDefault(); + url = $("#activity_types_filter_select option:selected" ).data('url'); + window.location = url; + }, + + }; +}); diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css new file mode 100644 index 0000000..e24a4ae --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css @@ -0,0 +1,103 @@ +.activity { + padding: 0; + list-style-type: none; + background: transparent url("/dotted.png") 21px 0 repeat-y; } + .activity .item { + position: relative; + margin: 0 0 15px 0; + padding: 0; } + .activity .item .user-image { + border-radius: 100px; } + .activity .item .date { + color: #999; + font-size: 12px; + white-space: nowrap; + margin: 5px 0 0 80px; } + .activity .item.no-avatar p { + margin-left: 40px; } + +.activity_buttons > a.btn.disabled { + color: inherit; + opacity: 0.70 !important; } + +.popover { + width: 300px; } + .popover .popover-title { + font-weight: bold; + margin-bottom: 0; } + .popover p.about { + margin: 0 0 10px 0; } + .popover .popover-close { + float: right; + text-decoration: none; } + .popover .empty { + padding: 10px; + color: #6e6e6e; + font-style: italic; } + +.activity .item .icon { + color: #999999; } +.activity .item.failure .icon { + color: #B95252; } +.activity .item.success .icon { + color: #69A67A; } +.activity .item.added-tag .icon { + color: #6995a6; } +.activity .item.changed-group .icon { + color: #767DCE; } +.activity .item.changed-package .icon { + color: #8c76ce; } +.activity .item.changed-package_extra .icon { + color: #769ace; } +.activity .item.changed-resource .icon { + color: #aa76ce; } +.activity .item.changed-user .icon { + color: #76b8ce; } +.activity .item.changed-organization .icon { + color: #699fa6; } +.activity .item.deleted-group .icon { + color: #B95252; } +.activity .item.deleted-package .icon { + color: #b97452; } +.activity .item.deleted-package_extra .icon { + color: #b95274; } +.activity .item.deleted-resource .icon { + color: #b99752; } +.activity .item.deleted-organization .icon { + color: #b95297; } +.activity .item.new-group .icon { + color: #69A67A; } +.activity .item.new-package .icon { + color: #69a68e; } +.activity .item.new-package_extra .icon { + color: #6ca669; } +.activity .item.new-resource .icon { + color: #81a669; } +.activity .item.new-user .icon { + color: #69a6a3; } +.activity .item.new-organization .icon { + color: #81a669; } +.activity .item.removed-tag .icon { + color: #b95297; } +.activity .item.deleted-related-item .icon { + color: #b9b952; } +.activity .item.follow-dataset .icon { + color: #767DCE; } +.activity .item.follow-user .icon { + color: #8c76ce; } +.activity .item.new-related-item .icon { + color: #95a669; } +.activity .item.follow-group .icon { + color: #8ba669; } + +.select-time { + width: 250px; + display: inline; } + +br.line-height2 { + line-height: 2; } + +.pull-right { + float: right; } + +/*# sourceMappingURL=activity.css.map */ diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css.map b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css.map new file mode 100644 index 0000000..76482e9 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.css.map @@ -0,0 +1,7 @@ +{ +"version": 3, +"mappings": "AAAA,SAAU;EACN,OAAO,EAAE,CAAC;EACV,eAAe,EAAE,IAAI;EACrB,UAAU,EAAE,8CAA+C;EAC3D,eAAM;IACF,QAAQ,EAAE,QAAQ;IAClB,MAAM,EAAE,UAAU;IAClB,OAAO,EAAE,CAAC;IACV,2BAAY;MACR,aAAa,EAAE,KAAK;IAExB,qBAAM;MACF,KAAK,EAAE,IAAI;MACX,SAAS,EAAE,IAAI;MACf,WAAW,EAAE,MAAM;MACnB,MAAM,EAAE,YAAY;IAExB,2BAAc;MAEV,WAAW,EAAE,IAAI;;AAK7B,kCAAmC;EAC/B,KAAK,EAAE,OAAO;EACd,OAAO,EAAE,eAAe;;AAG5B,QAAS;EACL,KAAK,EAAE,KAAK;EACZ,uBAAe;IACX,WAAW,EAAE,IAAI;IACjB,aAAa,EAAE,CAAC;EAEpB,gBAAQ;IACJ,MAAM,EAAE,UAAU;EAEtB,uBAAe;IACX,KAAK,EAAE,KAAK;IACZ,eAAe,EAAE,IAAI;EAEzB,eAAO;IACH,OAAO,EAAE,IAAI;IACb,KAAK,EAAE,OAAO;IACd,UAAU,EAAE,MAAM;;AAatB,qBAAM;EACF,KAAK,EARQ,OAAO;AAUxB,6BAAgB;EACZ,KAAK,EAPS,OAAO;AASzB,6BAAgB;EACZ,KAAK,EAbM,OAAO;AAetB,+BAAkB;EACd,KAAK,EAAE,OAAiC;AAE5C,mCAAsB;EAClB,KAAK,EAjBS,OAAO;AAmBzB,qCAAwB;EACpB,KAAK,EAAE,OAAoC;AAE/C,2CAA8B;EAC1B,KAAK,EAAE,OAAqC;AAEhD,sCAAyB;EACrB,KAAK,EAAE,OAAoC;AAE/C,kCAAqB;EACjB,KAAK,EAAE,OAAqC;AAEhD,0CAA6B;EACzB,KAAK,EAAE,OAAiC;AAE5C,mCAAsB;EAClB,KAAK,EAlCS,OAAO;AAoCzB,qCAAwB;EACpB,KAAK,EAAE,OAAoC;AAE/C,2CAA8B;EAC1B,KAAK,EAAE,OAAqC;AAEhD,sCAAyB;EACrB,KAAK,EAAE,OAAoC;AAE/C,0CAA6B;EACzB,KAAK,EAAE,OAAqC;AAEhD,+BAAkB;EACd,KAAK,EApDM,OAAO;AAsDtB,iCAAoB;EAChB,KAAK,EAAE,OAAiC;AAE5C,uCAA0B;EACtB,KAAK,EAAE,OAAkC;AAE7C,kCAAqB;EACjB,KAAK,EAAE,OAAkC;AAE7C,8BAAiB;EACb,KAAK,EAAE,OAAiC;AAE5C,sCAAyB;EACrB,KAAK,EAAE,OAAkC;AAE7C,iCAAoB;EAChB,KAAK,EAAE,OAAqC;AAEhD,0CAA6B;EACzB,KAAK,EAAE,OAAoC;AAE/C,oCAAuB;EACnB,KAAK,EA3EU,OAAO;AA6E1B,iCAAoB;EAChB,KAAK,EAAE,OAAqC;AAEhD,sCAAyB;EACrB,KAAK,EAAE,OAAkC;AAE7C,kCAAqB;EACjB,KAAK,EAAE,OAAkC;;AAIjD,YAAa;EACX,KAAK,EAAE,KAAK;EACZ,OAAO,EAAE,MAAM;;AAGjB,eAAgB;EACd,WAAW,EAAE,CAAC;;AAGhB,WAAY;EACR,KAAK,EAAE,KAAK", +"sources": ["activity.scss"], +"names": [], +"file": "activity.css" +} diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/activity.scss b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.scss new file mode 100644 index 0000000..1cf52dc --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/activity.scss @@ -0,0 +1,154 @@ +.activity { + padding: 0; + list-style-type: none; + background: transparent url('/dotted.png') 21px 0 repeat-y; + .item { + position: relative; + margin: 0 0 15px 0; + padding: 0; + .user-image { + border-radius: 100px; + } + .date { + color: #999; + font-size: 12px; + white-space: nowrap; + margin: 5px 0 0 80px; + } + &.no-avatar p { + // Use by datapusher + margin-left: 40px; + } + } +} + +.activity_buttons > a.btn.disabled { + color: inherit; + opacity: 0.70 !important; +} + +// For profile information that appears in the popover +.popover { + width: 300px; + .popover-title { + font-weight: bold; + margin-bottom: 0; + } + p.about { + margin: 0 0 10px 0; + } + .popover-close { + float: right; + text-decoration: none; + } + .empty { + padding: 10px; + color: #6e6e6e; + font-style: italic; + } +} + +// Activity Stream base colors +$activityColorText: #FFFFFF; +$activityColorBlank: #999999; +$activityColorNew: #69A67A; +$activityColorNeutral: #767DCE; +$activityColorModify: #767DCE; +$activityColorDelete: #B95252; + +.activity .item { + .icon { + color: $activityColorBlank ; + } // Non defined + &.failure .icon { + color: $activityColorDelete; + } + &.success .icon { + color: $activityColorNew; + } + &.added-tag .icon { + color: adjust-hue($activityColorNew, 60); + } + &.changed-group .icon { + color: $activityColorModify; + } + &.changed-package .icon { + color: adjust-hue($activityColorModify, 20); + } + &.changed-package_extra .icon { + color: adjust-hue($activityColorModify, -20); + } + &.changed-resource .icon { + color: adjust-hue($activityColorModify, 40); + } + &.changed-user .icon { + color: adjust-hue($activityColorModify, -40); + } + &.changed-organization .icon { + color: adjust-hue($activityColorNew, 50); + } + &.deleted-group .icon { + color: $activityColorDelete; + } + &.deleted-package .icon { + color: adjust-hue($activityColorDelete, 20); + } + &.deleted-package_extra .icon { + color: adjust-hue($activityColorDelete, -20); + } + &.deleted-resource .icon { + color: adjust-hue($activityColorDelete, 40); + } + &.deleted-organization .icon { + color: adjust-hue($activityColorDelete, -40); + } + &.new-group .icon { + color: $activityColorNew; + } + &.new-package .icon { + color: adjust-hue($activityColorNew, 20); + } + &.new-package_extra .icon { + color: adjust-hue($activityColorNew, -20); + } + &.new-resource .icon { + color: adjust-hue($activityColorNew, -40); + } + &.new-user .icon { + color: adjust-hue($activityColorNew, 40); + } + &.new-organization .icon { + color: adjust-hue($activityColorNew, -40); + } + &.removed-tag .icon { + color: adjust-hue($activityColorDelete, -40); + } + &.deleted-related-item .icon { + color: adjust-hue($activityColorDelete, 60); + } + &.follow-dataset .icon { + color: $activityColorNeutral; + } + &.follow-user .icon { + color: adjust-hue($activityColorNeutral, 20); + } + &.new-related-item .icon { + color: adjust-hue($activityColorNew, -60); + } + &.follow-group .icon { + color: adjust-hue($activityColorNew, -50); + } +} + +.select-time { + width: 250px; + display: inline; +} + +br.line-height2 { + line-height: 2; +} + +.pull-right { + float: right; +} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.js b/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.js new file mode 100644 index 0000000..69b97c7 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.js @@ -0,0 +1,95 @@ +/* User Dashboard + * Handles the filter dropdown menu and the reduction of the notifications number + * within the header to zero + * + * Examples + * + *
+ * + */ +this.ckan.module('dashboard', function ($) { + return { + button: null, + popover: null, + searchTimeout: null, + + /* Initialises the module setting up elements and event listeners. + * + * Returns nothing. + */ + initialize: function () { + $.proxyAll(this, /_on/); + this.button = $('#followee-filter .btn'). + on('click', this._onShowFolloweeDropdown); + var title = this.button.prop('title'); + + this.button.popover = new bootstrap.Popover(document.querySelector('#followee-filter .btn'), { + placement: 'bottom', + html: true, + template: '', + customClass: 'popover-followee', + sanitizeFn: function (content) { + return DOMPurify.sanitize(content, { ALLOWED_TAGS: [ + "form", "div", "input", "footer", "header", "h1", "h2", "h3", "h4", + "small", "span", "strong", "i", 'a', 'li', 'ul','p' + + ]}); + }, + content: $('#followee-content').html() + }); + this.button.prop('title', title); + }, + + /* Handles click event on the 'show me:' dropdown button + * + * Returns nothing. + */ + _onShowFolloweeDropdown: function() { + this.button.toggleClass('active'); + if (this.button.hasClass('active')) { + setTimeout(this._onInitSearch, 100); + } + return false; + }, + + /* Handles focusing on the input and making sure that the keyup + * even is applied to the input + * + * Returns nothing. + */ + _onInitSearch: function() { + var input = $('input', this.popover); + if (!input.hasClass('inited')) { + input. + on('keyup', this._onSearchKeyUp). + addClass('inited'); + } + input.focus(); + }, + + /* Handles the keyup event + * + * Returns nothing. + */ + _onSearchKeyUp: function() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(this._onSearchKeyUpTimeout, 300); + }, + + /* Handles the actual filtering of search results + * + * Returns nothing. + */ + _onSearchKeyUpTimeout: function() { + this.popover = this.button.popover.tip; + var input = $('input', this.popover); + var q = input.val().toLowerCase(); + if (q) { + $('li', this.popover).hide(); + $('li.everything, [data-search^="' + q + '"]', this.popover).show(); + } else { + $('li', this.popover).show(); + } + } + }; +}); diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.spec.js b/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.spec.js new file mode 100644 index 0000000..95ef2f2 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/dashboard.spec.js @@ -0,0 +1,64 @@ +describe('ckan.modules.DashboardModule()', function () { + before(() => { + cy.visit('/'); + cy.window().then(win => { + cy.wrap(win.ckan.module.registry['dashboard']).as('dashboard'); + win.jQuery('
').appendTo(win.document.body) + cy.loadFixture('dashboard.html').then((template) => { + cy.wrap(template).as('template'); + }); + }) + }); + + beforeEach(function () { + cy.window().then(win => { + win.jQuery('#fixture').html(this.template); + this.el = document.createElement('button'); + this.sandbox = win.ckan.sandbox(); + this.sandbox.body = win.jQuery('#fixture'); + cy.wrap(this.sandbox.body).as('fixture'); + this.module = new this.dashboard(this.el, {}, this.sandbox); + }) + }); + + afterEach(function () { + //this.fixture.empty(); + }); + + describe('.initialize()', function () { + it('should bind callback methods to the module', function () { + cy.window().then(win => { + let target = cy.stub(win.jQuery, 'proxyAll'); + + this.module.initialize(); + + expect(target).to.be.called; + expect(target).to.be.calledWith(this.module, /_on/); + + target.restore(); + }) + }) + }) + + describe('.show()', function () { + it('should append the popover to the document body', function () { + this.module.initialize(); + this.module.button.click(); + assert.equal(this.fixture.children().length, 1); + assert.equal(this.fixture.find('#followee-filter').length, 1); + assert.equal(this.fixture.find('#followee-filter .input-group input').length, 1); + }); + }) + + describe(".search", function(){ + it('should filter based on query', function() { + this.module.initialize(); + this.module.button.click(); + cy.get('#fixture #followee-filter #followee-content').invoke('removeAttr', 'style'); + cy.get('#fixture #followee-filter .nav li').should('have.length', 3); + cy.get('#fixture #followee-filter .input-group input.inited').type('text'); + cy.get('#fixture #followee-filter .nav li[data-search="not valid"]').should('be.visible'); + cy.get('#fixture #followee-filter .nav li[data-search="test followee"]').should('be.visible'); + }) + }) +}) diff --git a/src/ckanext-d4science_theme/ckanext/activity/assets/webassets.yml b/src/ckanext-d4science_theme/ckanext/activity/assets/webassets.yml new file mode 100644 index 0000000..e762585 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/assets/webassets.yml @@ -0,0 +1,14 @@ +activity: + filters: rjsmin + output: activity/%(version)s_activity.js + extra: + preload: + - base/main + contents: + - dashboard.js + - activity-stream.js + +activity-css: + output: ckanext-activity/%(version)s_activity.css + contents: + - activity.css diff --git a/src/ckanext-d4science_theme/ckanext/activity/changes.py b/src/ckanext-d4science_theme/ckanext/activity/changes.py new file mode 100644 index 0000000..81f090f --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/changes.py @@ -0,0 +1,1255 @@ +# encoding: utf-8 +""" +Functions for generating a list of differences between two versions of a +dataset +""" +from __future__ import annotations + +import logging +from typing import Any +from typing_extensions import TypeAlias, TypedDict + +log = logging.getLogger(__name__) + +Data: TypeAlias = "dict[str, Any]" +ChangeList: TypeAlias = "list[Data]" + + +class Extra(TypedDict): + key: str + value: Any + + +def _extras_to_dict(extras_list: list[Extra]) -> Data: + """ + Takes a list of dictionaries with the following format: + [ + { + "key": , + "value": + }, + ..., + { + "key": , + "value": + } + ] + and converts it into a single dictionary with the following + format: + { + key_0: value_0, + ..., + key_n: value_n + + } + """ + ret_dict = {} + # the extras_list is a list of dictionaries + for dict in extras_list: + ret_dict[dict["key"]] = dict["value"] + + return ret_dict + + +def check_resource_changes( + change_list: ChangeList, old: Data, new: Data, old_activity_id: str +) -> None: + """ + Compares two versions of a dataset and records the changes between them + (just the resources) in change_list. e.g. resources that are added, changed + or deleted. For existing resources, checks whether their names, formats, + and/or descriptions have changed, as well as whether the url changed (e.g. + a new file has been uploaded for the resource). + """ + + # list of default fields in a resource's metadata dictionary - used + # later to ensure that we don't count changes to default fields as changes + # to extra fields + fields = [ + "package_id", + "url", + "revision_id", + "description", + "format", + "hash", + "name", + "resource_type", + "mimetype", + "mimetype_inner", + "cache_url", + "size", + "created", + "last_modified", + "metadata_modified", + "cache_last_updated", + "upload", + "position", + ] + default_fields_set = set(fields) + + # make a set of the resource IDs present in old and new + old_resource_set = set() + old_resource_dict = {} + new_resource_set = set() + new_resource_dict = {} + + for resource in old.get("resources", []): + old_resource_set.add(resource["id"]) + old_resource_dict[resource["id"]] = { + key: value for (key, value) in resource.items() if key != "id" + } + + for resource in new.get("resources", []): + new_resource_set.add(resource["id"]) + new_resource_dict[resource["id"]] = { + key: value for (key, value) in resource.items() if key != "id" + } + + # get the IDs of the resources that have been added between the versions + new_resources = list(new_resource_set - old_resource_set) + for resource_id in new_resources: + change_list.append( + { + "type": "new_resource", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_name": new_resource_dict[resource_id].get("name"), + "resource_id": resource_id, + } + ) + + # get the IDs of resources that have been deleted between versions + deleted_resources = list(old_resource_set - new_resource_set) + for resource_id in deleted_resources: + change_list.append( + { + "type": "delete_resource", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": old_resource_dict[resource_id].get("name"), + "old_activity_id": old_activity_id, + } + ) + + # now check the resources that are in both and see if any + # have been changed + resources = new_resource_set.intersection(old_resource_set) + for resource_id in resources: + old_metadata = old_resource_dict[resource_id] + new_metadata = new_resource_dict[resource_id] + + if old_metadata.get("name") != new_metadata.get("name"): + change_list.append( + { + "type": "resource_name", + "title": new.get("title"), + "old_pkg_id": old["id"], + "new_pkg_id": new["id"], + "resource_id": resource_id, + "old_resource_name": old_resource_dict[resource_id].get( + "name" + ), + "new_resource_name": new_resource_dict[resource_id].get( + "name" + ), + "old_activity_id": old_activity_id, + } + ) + + # you can't remove a format, but if a resource's format isn't + # recognized, it won't have one set + + # if a format was not originally set and the user set one + if not old_metadata.get("format") and new_metadata.get("format"): + change_list.append( + { + "type": "resource_format", + "method": "add", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_resource_dict[resource_id].get( + "name" + ), + "org_id": new["organization"]["id"] + if new.get("organization") + else "", + "format": new_metadata.get("format"), + } + ) + + # if both versions have a format but the format changed + elif old_metadata.get("format") != new_metadata.get("format"): + change_list.append( + { + "type": "resource_format", + "method": "change", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_resource_dict[resource_id].get( + "name" + ), + "org_id": new["organization"]["id"] + if new.get("organization") + else "", + "old_format": old_metadata.get("format"), + "new_format": new_metadata.get("format"), + } + ) + + # if the description changed + if not old_metadata.get("description") and new_metadata.get( + "description" + ): + change_list.append( + { + "type": "resource_desc", + "method": "add", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_resource_dict[resource_id].get( + "name" + ), + "new_desc": new_metadata.get("description"), + } + ) + + # if there was a description but the user removed it + elif old_metadata.get("description") and not new_metadata.get( + "description" + ): + change_list.append( + { + "type": "resource_desc", + "method": "remove", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_resource_dict[resource_id].get( + "name" + ), + } + ) + + # if both have descriptions but they are different + elif old_metadata.get("description") != new_metadata.get( + "description" + ): + change_list.append( + { + "type": "resource_desc", + "method": "change", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_resource_dict[resource_id].get( + "name" + ), + "new_desc": new_metadata.get("description"), + "old_desc": old_metadata.get("description"), + } + ) + + # check if the url changes (e.g. user uploaded a new file) + # TODO: use regular expressions to determine the actual name of the + # new and old files + if old_metadata.get("url") != new_metadata.get("url"): + change_list.append( + { + "type": "new_file", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + } + ) + + # check any extra fields in the resource + # remove default fields from these sets to make sure we only check + # for changes to extra fields + old_fields_set = set(old_metadata.keys()) + old_fields_set = old_fields_set - default_fields_set + new_fields_set = set(new_metadata.keys()) + new_fields_set = new_fields_set - default_fields_set + + # determine if any new extra fields have been added + new_fields = list(new_fields_set - old_fields_set) + if len(new_fields) == 1: + if new_metadata[new_fields[0]]: + change_list.append( + { + "type": "resource_extras", + "method": "add_one_value", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": new_fields[0], + "value": new_metadata[new_fields[0]], + } + ) + else: + change_list.append( + { + "type": "resource_extras", + "method": "add_one_no_value", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": new_fields[0], + } + ) + elif len(new_fields) > 1: + change_list.append( + { + "type": "resource_extras", + "method": "add_multiple", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key_list": new_fields, + "value_list": [ + new_metadata[field] for field in new_fields + ], + } + ) + + # determine if any extra fields have been removed + deleted_fields = list(old_fields_set - new_fields_set) + if len(deleted_fields) == 1: + change_list.append( + { + "type": "resource_extras", + "method": "remove_one", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": deleted_fields[0], + } + ) + elif len(deleted_fields) > 1: + change_list.append( + { + "type": "resource_extras", + "method": "remove_multiple", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key_list": deleted_fields, + } + ) + + # determine if any extra fields have been changed + # changed_fields is only a set of POTENTIALLY changed fields - we + # still have to check if any of the values associated with the fields + # have actually changed + changed_fields = list(new_fields_set.intersection(old_fields_set)) + for field in changed_fields: + if new_metadata[field] != old_metadata[field]: + if new_metadata[field] and old_metadata[field]: + change_list.append( + { + "type": "resource_extras", + "method": "change_value_with_old", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": field, + "old_value": old_metadata[field], + "new_value": new_metadata[field], + } + ) + elif not old_metadata[field]: + change_list.append( + { + "type": "resource_extras", + "method": "change_value_no_old", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": field, + "new_value": new_metadata[field], + } + ) + elif not new_metadata[field]: + change_list.append( + { + "type": "resource_extras", + "method": "change_value_no_new", + "pkg_id": new["id"], + "title": new.get("title"), + "resource_id": resource_id, + "resource_name": new_metadata.get("name"), + "key": field, + } + ) + + +def check_metadata_changes( + change_list: ChangeList, old: Data, new: Data +) -> None: + """ + Compares two versions of a dataset and records the changes between them + (excluding resources) in change_list. + """ + # if the title has changed + if old.get("title") != new.get("title"): + _title_change(change_list, old, new) + + # if the owner organization changed + if old.get("owner_org") != new.get("owner_org"): + _org_change(change_list, old, new) + + # if the maintainer of the dataset changed + if old.get("maintainer") != new.get("maintainer"): + _maintainer_change(change_list, old, new) + + # if the maintainer email of the dataset changed + if old.get("maintainer_email") != new.get("maintainer_email"): + _maintainer_email_change(change_list, old, new) + + # if the author of the dataset changed + if old.get("author") != new.get("author"): + _author_change(change_list, old, new) + + # if the author email of the dataset changed + if old.get("author_email") != new.get("author_email"): + _author_email_change(change_list, old, new) + + # if the visibility of the dataset changed + if old.get("private") != new.get("private"): + change_list.append( + { + "type": "private", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new": "Private" if bool(new.get("private")) else "Public", + } + ) + + # if the description of the dataset changed + if old.get("notes") != new.get("notes"): + _notes_change(change_list, old, new) + + # make sets out of the tags for each dataset + old_tags = {tag.get("name") for tag in old.get("tags", [])} + new_tags = {tag.get("name") for tag in new.get("tags", [])} + # if the tags have changed + if old_tags != new_tags: + _tag_change(change_list, new_tags, old_tags, new) + + # if the license has changed + if old.get("license_title") != new.get("license_title"): + _license_change(change_list, old, new) + + # if the name of the dataset has changed + # this is only visible to the user via the dataset's URL, + # so display the change using that + if old.get("name") != new.get("name"): + _name_change(change_list, old, new) + + # if the source URL (metadata value, not the actual URL of the dataset) + # has changed + if old.get("url") != new.get("url"): + _url_change(change_list, old, new) + + # if the user-provided version has changed + if old.get("version") != new.get("version"): + _version_change(change_list, old, new) + + # check whether fields added by extensions or custom fields + # (in the "extras" field) have been changed + + _extension_fields(change_list, old, new) + _extra_fields(change_list, old, new) + + +def check_metadata_org_changes(change_list: ChangeList, old: Data, new: Data): + """ + Compares two versions of a organization and records the changes between + them in change_list. + """ + # if the title has changed + if old.get("title") != new.get("title"): + _title_change(change_list, old, new) + + # if the description of the organization changed + if old.get("description") != new.get("description"): + _description_change(change_list, old, new) + + # if the image URL has changed + if old.get("image_url") != new.get("image_url"): + _image_url_change(change_list, old, new) + + +def _title_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's title between two versions + (old and new) to change_list. + """ + change_list.append( + { + "type": "title", + "id": new.get("name"), + "new_title": new.get("title"), + "old_title": old.get("title"), + } + ) + + +def _org_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's organization between + two versions (old and new) to change_list. + """ + + # if both versions belong to an organization + if old.get("owner_org") and new.get("owner_org"): + change_list.append( + { + "type": "org", + "method": "change", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_org_id": old["organization"].get("id"), + "old_org_title": old["organization"].get("title"), + "new_org_id": new["organization"].get("id"), + "new_org_title": new["organization"].get("title"), + } + ) + # if the dataset was not in an organization before and it is now + elif not old.get("owner_org") and new.get("owner_org"): + change_list.append( + { + "type": "org", + "method": "add", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_org_id": new["organization"].get("id"), + "new_org_title": new["organization"].get("title"), + } + ) + # if the user removed the organization + else: + change_list.append( + { + "type": "org", + "method": "remove", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_org_id": old["organization"].get("id"), + "old_org_title": old["organization"].get("title"), + } + ) + + +def _maintainer_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's maintainer field between two + versions (old and new) to change_list. + """ + # if the old dataset had a maintainer + if old.get("maintainer") and new.get("maintainer"): + change_list.append( + { + "type": "maintainer", + "method": "change", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_maintainer": new["maintainer"], + "old_maintainer": old["maintainer"], + } + ) + # if they removed the maintainer + elif not new.get("maintainer"): + change_list.append( + { + "type": "maintainer", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "maintainer", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_maintainer": new.get("maintainer"), + "method": "add", + } + ) + + +def _maintainer_email_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's maintainer e-mail address + field between two versions (old and new) to change_list. + """ + # if the old dataset had a maintainer email + if old.get("maintainer_email") and new.get("maintainer_email"): + change_list.append( + { + "type": "maintainer_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_maintainer_email": new.get("maintainer_email"), + "old_maintainer_email": old.get("maintainer_email"), + "method": "change", + } + ) + # if they removed the maintainer email + elif not new.get("maintainer_email"): + change_list.append( + { + "type": "maintainer_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + # if there wasn't one there before e + else: + change_list.append( + { + "type": "maintainer_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_maintainer_email": new.get("maintainer_email"), + "method": "add", + } + ) + + +def _author_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's author field between two + versions (old and new) to change_list. + """ + # if the old dataset had an author + if old.get("author") and new.get("author"): + change_list.append( + { + "type": "author", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_author": new.get("author"), + "old_author": old.get("author"), + "method": "change", + } + ) + # if they removed the author + elif not new.get("author"): + change_list.append( + { + "type": "author", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "author", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_author": new.get("author"), + "method": "add", + } + ) + + +def _author_email_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's author e-mail address field + between two versions (old and new) to change_list. + """ + if old.get("author_email") and new.get("author_email"): + change_list.append( + { + "type": "author_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_author_email": new.get("author_email"), + "old_author_email": old.get("author_email"), + "method": "change", + } + ) + # if they removed the author + elif not new.get("author_email"): + change_list.append( + { + "type": "author_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "author_email", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_author_email": new.get("author_email"), + "method": "add", + } + ) + + +def _notes_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's description between two + versions (old and new) to change_list. + """ + # if the old dataset had a description + if old.get("notes") and new.get("notes"): + change_list.append( + { + "type": "notes", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_notes": new.get("notes"), + "old_notes": old.get("notes"), + "method": "change", + } + ) + elif not new.get("notes"): + change_list.append( + { + "type": "notes", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + else: + change_list.append( + { + "type": "notes", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_notes": new.get("notes"), + "method": "add", + } + ) + + +def _tag_change( + change_list: ChangeList, new_tags: set[Any], old_tags: set[Any], new: Data +): + """ + Appends a summary of a change to a dataset's tag list between two + versions (old and new) to change_list. + """ + deleted_tags = old_tags - new_tags + deleted_tags_list = list(deleted_tags) + if len(deleted_tags) == 1: + change_list.append( + { + "type": "tags", + "method": "remove_one", + "pkg_id": new.get("id"), + "title": new.get("title"), + "tag": deleted_tags_list[0], + } + ) + elif len(deleted_tags) > 1: + change_list.append( + { + "type": "tags", + "method": "remove_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "tags": deleted_tags_list, + } + ) + + added_tags = new_tags - old_tags + added_tags_list = list(added_tags) + if len(added_tags) == 1: + change_list.append( + { + "type": "tags", + "method": "add_one", + "pkg_id": new.get("id"), + "title": new.get("title"), + "tag": added_tags_list[0], + } + ) + elif len(added_tags) > 1: + change_list.append( + { + "type": "tags", + "method": "add_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "tags": added_tags_list, + } + ) + + +def _license_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's license between two versions + (old and new) to change_list. + """ + old_license_url = "" + new_license_url = "" + # if the license has a URL + if "license_url" in old and old["license_url"]: + old_license_url = old["license_url"] + if "license_url" in new and new["license_url"]: + new_license_url = new["license_url"] + change_list.append( + { + "type": "license", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_url": old_license_url, + "new_url": new_license_url, + "new_title": new.get("license_title"), + "old_title": old.get("license_title"), + } + ) + + +def _name_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's name (and thus the URL it + can be accessed at) between two versions (old and new) to + change_list. + """ + change_list.append( + { + "type": "name", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_name": old.get("name"), + "new_name": new.get("name"), + } + ) + + +def _url_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's source URL (metadata field, + not its actual URL in the datahub) between two versions (old and + new) to change_list. + """ + # if both old and new versions have source URLs + if old.get("url") and new.get("url"): + change_list.append( + { + "type": "url", + "method": "change", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_url": new.get("url"), + "old_url": old.get("url"), + } + ) + # if the user removed the source URL + elif not new.get("url"): + change_list.append( + { + "type": "url", + "method": "remove", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_url": old.get("url"), + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "url", + "method": "add", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_url": new.get("url"), + } + ) + + +def _version_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a dataset's version field (inputted + by the user, not from version control) between two versions (old + and new) to change_list. + """ + # if both old and new versions have version numbers + if old.get("version") and new.get("version"): + change_list.append( + { + "type": "version", + "method": "change", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_version": old.get("version"), + "new_version": new.get("version"), + } + ) + # if the user removed the version number + elif not new.get("version"): + change_list.append( + { + "type": "version", + "method": "remove", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_version": old.get("version"), + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "version", + "method": "add", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_version": new.get("version"), + } + ) + + +def _extension_fields(change_list: ChangeList, old: Data, new: Data): + """ + Checks whether any fields that have been added to the package + dictionaries by CKAN extensions have been changed between versions. + If there have been any changes between the two versions (old and + new), a general summary of the change is appended to change_list. This + function does not produce summaries for fields added or deleted by + extensions, since these changes are not triggered by the user in the web + interface or API. + """ + # list of the default metadata fields for a dataset + # any fields that are not part of this list are custom fields added by a + # user or extension + fields = [ + "owner_org", + "maintainer", + "maintainer_email", + "relationships_as_object", + "private", + "num_tags", + "id", + "metadata_created", + "metadata_modified", + "author", + "author_email", + "state", + "version", + "license_id", + "type", + "resources", + "num_resources", + "tags", + "title", + "groups", + "creator_user_id", + "relationships_as_subject", + "name", + "isopen", + "url", + "notes", + "license_title", + "extras", + "license_url", + "organization", + "revision_id", + ] + fields_set = set(fields) + + # if there are any fields from extensions that are in the new dataset and + # have been updated, print a generic message stating that + old_set = set(old.keys()) + new_set = set(new.keys()) + + # set of additional fields in the new dictionary + addl_fields_new = new_set - fields_set + # set of additional fields in the old dictionary + addl_fields_old = old_set - fields_set + # set of additional fields in both + addl_fields = addl_fields_new.intersection(addl_fields_old) + + # do NOT display a change if any additional fields have been + # added or deleted, since that is not a change made by the user + # from the web interface + + # if additional fields have been changed + addl_fields_list = list(addl_fields) + for field in addl_fields_list: + if old.get(field) != new.get(field): + change_list.append( + { + "type": "extension_fields", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": field, + "value": new.get(field), + } + ) + + +def _extra_fields(change_list: ChangeList, old: Data, new: Data): + """ + Checks whether a user has added, removed, or changed any extra fields + from the web interface (or API?) and appends a summary of each change to + change_list. + """ + if "extras" in new: + extra_fields_new = _extras_to_dict(new.get("extras", [])) + extra_new_set = set(extra_fields_new.keys()) + + # if the old version has extra fields, we need + # to compare the new version's extras to the old ones + if "extras" in old: + extra_fields_old = _extras_to_dict(old.get("extras", [])) + extra_old_set = set(extra_fields_old.keys()) + + # if some fields were added + new_fields = list(extra_new_set - extra_old_set) + if len(new_fields) == 1: + if extra_fields_new[new_fields[0]]: + change_list.append( + { + "type": "extra_fields", + "method": "add_one_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": new_fields[0], + "value": extra_fields_new[new_fields[0]], + } + ) + else: + change_list.append( + { + "type": "extra_fields", + "method": "add_one_no_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": new_fields[0], + } + ) + elif len(new_fields) > 1: + change_list.append( + { + "type": "extra_fields", + "method": "add_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key_list": new_fields, + "value_list": extra_fields_new, + } + ) + + # if some fields were deleted + deleted_fields = list(extra_old_set - extra_new_set) + if len(deleted_fields) == 1: + change_list.append( + { + "type": "extra_fields", + "method": "remove_one", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": deleted_fields[0], + } + ) + elif len(deleted_fields) > 1: + change_list.append( + { + "type": "extra_fields", + "method": "remove_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key_list": deleted_fields, + } + ) + + # if some existing fields were changed + # list of extra fields in both the old and new versions + extra_fields = list(extra_new_set.intersection(extra_old_set)) + for field in extra_fields: + if extra_fields_old[field] != extra_fields_new[field]: + if extra_fields_old[field]: + change_list.append( + { + "type": "extra_fields", + "method": "change_with_old_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": field, + "old_value": extra_fields_old[field], + "new_value": extra_fields_new[field], + } + ) + else: + change_list.append( + { + "type": "extra_fields", + "method": "change_no_old_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": field, + "new_value": extra_fields_new[field], + } + ) + + # if the old version didn't have an extras field, + # the user could only have added a field (not changed or deleted) + else: + new_fields = list(extra_new_set) + if len(new_fields) == 1: + if extra_fields_new[new_fields[0]]: + change_list.append( + { + "type": "extra_fields", + "method": "add_one_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": new_fields[0], + "value": extra_fields_new[new_fields[0]], + } + ) + else: + change_list.append( + { + "type": "extra_fields", + "method": "add_one_no_value", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": new_fields[0], + } + ) + + elif len(new_fields) > 1: + change_list.append( + { + "type": "extra_fields", + "method": "add_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key_list": new_fields, + "value_list": extra_fields_new, + } + ) + + elif "extras" in old: + deleted_fields = list(_extras_to_dict(old["extras"]).keys()) + if len(deleted_fields) == 1: + change_list.append( + { + "type": "extra_fields", + "method": "remove_one", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key": deleted_fields[0], + } + ) + elif len(deleted_fields) > 1: + change_list.append( + { + "type": "extra_fields", + "method": "remove_multiple", + "pkg_id": new.get("id"), + "title": new.get("title"), + "key_list": deleted_fields, + } + ) + + +def _description_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a organization's description between two + versions (old and new) to change_list. + """ + + # if the old organization had a description + if old.get("description") and new.get("description"): + change_list.append( + { + "type": "description", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_description": new.get("description"), + "old_description": old.get("description"), + "method": "change", + } + ) + elif not new.get("description"): + change_list.append( + { + "type": "description", + "pkg_id": new.get("id"), + "title": new.get("title"), + "method": "remove", + } + ) + else: + change_list.append( + { + "type": "description", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_description": new.get("description"), + "method": "add", + } + ) + + +def _image_url_change(change_list: ChangeList, old: Data, new: Data): + """ + Appends a summary of a change to a organization's image URL between two + versions (old and new) to change_list. + """ + # if both old and new versions have image URLs + if old.get("image_url") and new.get("image_url"): + change_list.append( + { + "type": "image_url", + "method": "change", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_image_url": new.get("image_url"), + "old_image_url": old.get("image_url"), + } + ) + # if the user removed the image URL + elif not new.get("image_url"): + change_list.append( + { + "type": "image_url", + "method": "remove", + "pkg_id": new.get("id"), + "title": new.get("title"), + "old_image_url": old.get("image_url"), + } + ) + # if there wasn't one there before + else: + change_list.append( + { + "type": "image_url", + "method": "add", + "pkg_id": new.get("id"), + "title": new.get("title"), + "new_image_url": new.get("image_url"), + } + ) diff --git a/src/ckanext-d4science_theme/ckanext/activity/email_notifications.py b/src/ckanext-d4science_theme/ckanext/activity/email_notifications.py new file mode 100644 index 0000000..d7c49d2 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/email_notifications.py @@ -0,0 +1,278 @@ +# encoding: utf-8 + +""" +Code for generating email notifications for users (e.g. email notifications for +new activities in your dashboard activity stream) and emailing them to the +users. + +""" +from __future__ import annotations + +import datetime +import re +from typing import Any, cast +from jinja2 import Environment + +import ckan.model as model +import ckan.logic as logic +import ckan.lib.jinja_extensions as jinja_extensions + +from ckan.common import ungettext, ugettext, config +from ckan.types import Context + + +def string_to_timedelta(s: str) -> datetime.timedelta: + """Parse a string s and return a standard datetime.timedelta object. + + Handles days, hours, minutes, seconds, and microseconds. + + Accepts strings in these formats: + + 2 days + 14 days + 4:35:00 (hours, minutes and seconds) + 4:35:12.087465 (hours, minutes, seconds and microseconds) + 7 days, 3:23:34 + 7 days, 3:23:34.087465 + .087465 (microseconds only) + + :raises ckan.logic.ValidationError: if the given string does not match any + of the recognised formats + + """ + patterns = [] + days_only_pattern = r"(?P\d+)\s+day(s)?" + patterns.append(days_only_pattern) + hms_only_pattern = r"(?P\d?\d):(?P\d\d):(?P\d\d)" + patterns.append(hms_only_pattern) + ms_only_pattern = r".(?P\d\d\d)(?P\d\d\d)" + patterns.append(ms_only_pattern) + hms_and_ms_pattern = hms_only_pattern + ms_only_pattern + patterns.append(hms_and_ms_pattern) + days_and_hms_pattern = r"{0},\s+{1}".format( + days_only_pattern, hms_only_pattern + ) + patterns.append(days_and_hms_pattern) + days_and_hms_and_ms_pattern = days_and_hms_pattern + ms_only_pattern + patterns.append(days_and_hms_and_ms_pattern) + + match = None + for pattern in patterns: + match = re.match("^{0}$".format(pattern), s) + if match: + break + + if not match: + raise logic.ValidationError( + {"message": "Not a valid time: {0}".format(s)} + ) + + gd = match.groupdict() + days = int(gd.get("days", "0")) + hours = int(gd.get("hours", "0")) + minutes = int(gd.get("minutes", "0")) + seconds = int(gd.get("seconds", "0")) + milliseconds = int(gd.get("milliseconds", "0")) + microseconds = int(gd.get("microseconds", "0")) + delta = datetime.timedelta( + days=days, + hours=hours, + minutes=minutes, + seconds=seconds, + milliseconds=milliseconds, + microseconds=microseconds, + ) + return delta + + +def render_activity_email(activities: list[dict[str, Any]]) -> str: + globals = {"site_title": config.get("ckan.site_title")} + template_name = "activity_streams/activity_stream_email_notifications.text" + + env = Environment(**jinja_extensions.get_jinja_env_options()) + # Install the given gettext, ngettext callables into the environment + env.install_gettext_callables(ugettext, ungettext) # type: ignore + + template = env.get_template(template_name, globals=globals) + return template.render({"activities": activities}) + + +def _notifications_for_activities( + activities: list[dict[str, Any]], user_dict: dict[str, Any] +) -> list[dict[str, str]]: + """Return one or more email notifications covering the given activities. + + This function handles grouping multiple activities into a single digest + email. + + :param activities: the activities to consider + :type activities: list of activity dicts like those returned by + ckan.logic.action.get.dashboard_activity_list() + + :returns: a list of email notifications + :rtype: list of dicts each with keys 'subject' and 'body' + + """ + if not activities: + return [] + + if not user_dict.get("activity_streams_email_notifications"): + return [] + + # We just group all activities into a single "new activity" email that + # doesn't say anything about _what_ new activities they are. + # TODO: Here we could generate some smarter content for the emails e.g. + # say something about the contents of the activities, or single out + # certain types of activity to be sent in their own individual emails, + # etc. + + subject = ungettext( + "{n} new activity from {site_title}", + "{n} new activities from {site_title}", + len(activities), + ).format(site_title=config.get("ckan.site_title"), n=len(activities)) + + body = render_activity_email(activities) + notifications = [{"subject": subject, "body": body}] + + return notifications + + +def _notifications_from_dashboard_activity_list( + user_dict: dict[str, Any], since: datetime.datetime +) -> list[dict[str, str]]: + """Return any email notifications from the given user's dashboard activity + list since `since`. + + """ + # Get the user's dashboard activity stream. + context = cast( + Context, + {"model": model, "session": model.Session, "user": user_dict["id"]}, + ) + activity_list = logic.get_action("dashboard_activity_list")(context, {}) + + # Filter out the user's own activities., so they don't get an email every + # time they themselves do something (we are not Trac). + activity_list = [ + activity + for activity in activity_list + if activity["user_id"] != user_dict["id"] + ] + + # Filter out the old activities. + strptime = datetime.datetime.strptime + fmt = "%Y-%m-%dT%H:%M:%S.%f" + activity_list = [ + activity + for activity in activity_list + if strptime(activity["timestamp"], fmt) > since + ] + + return _notifications_for_activities(activity_list, user_dict) + + +# A list of functions that provide email notifications for users from different +# sources. Add to this list if you want to implement a new source of email +# notifications. +_notifications_functions = [ + _notifications_from_dashboard_activity_list, +] + + +def get_notifications( + user_dict: dict[str, Any], since: datetime.datetime +) -> list[dict[str, Any]]: + """Return any email notifications for the given user since `since`. + + For example email notifications about activity streams will be returned for + any activities the occurred since `since`. + + :param user_dict: a dictionary representing the user, should contain 'id' + and 'name' + :type user_dict: dictionary + + :param since: datetime after which to return notifications from + :rtype since: datetime.datetime + + :returns: a list of email notifications + :rtype: list of dicts with keys 'subject' and 'body' + + """ + notifications = [] + for function in _notifications_functions: + notifications.extend(function(user_dict, since)) + return notifications + + +def send_notification( + user: dict[str, Any], email_dict: dict[str, Any] +) -> None: + """Email `email_dict` to `user`.""" + import ckan.lib.mailer + + if not user.get("email"): + # FIXME: Raise an exception. + return + + try: + ckan.lib.mailer.mail_recipient( + user["display_name"], + user["email"], + email_dict["subject"], + email_dict["body"], + ) + except ckan.lib.mailer.MailerException: + raise + + +def get_and_send_notifications_for_user(user: dict[str, Any]) -> None: + + # Parse the email_notifications_since config setting, email notifications + # from longer ago than this time will not be sent. + email_notifications_since = config.get( + "ckan.email_notifications_since" + ) + email_notifications_since = string_to_timedelta(email_notifications_since) + email_notifications_since = ( + datetime.datetime.utcnow() - email_notifications_since + ) + + # FIXME: We are accessing model from lib here but I'm not sure what + # else to do unless we add a get_email_last_sent() logic function which + # would only be needed by this lib. + dashboard = model.Dashboard.get(user["id"]) + if dashboard: + email_last_sent = dashboard.email_last_sent + activity_stream_last_viewed = dashboard.activity_stream_last_viewed + since = max( + email_notifications_since, + email_last_sent, + activity_stream_last_viewed, + ) + + notifications = get_notifications(user, since) + # TODO: Handle failures from send_email_notification. + for notification in notifications: + send_notification(user, notification) + + # FIXME: We are accessing model from lib here but I'm not sure what + # else to do unless we add a update_email_last_sent() + # logic function which would only be needed by this lib. + dashboard.email_last_sent = datetime.datetime.utcnow() + model.repo.commit() + + +def get_and_send_notifications_for_all_users() -> None: + context = cast( + Context, + { + "model": model, + "session": model.Session, + "ignore_auth": True, + "keep_email": True, + }, + ) + users = logic.get_action("user_list")(context, {}) + for user in users: + get_and_send_notifications_for_user(user) diff --git a/src/ckanext-d4science_theme/ckanext/activity/helpers.py b/src/ckanext-d4science_theme/ckanext/activity/helpers.py new file mode 100644 index 0000000..a6d8953 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/helpers.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations +from typing import Any, Optional, cast + +import jinja2 +import datetime +from markupsafe import Markup + +import ckan.model as model +import ckan.plugins.toolkit as tk + +from ckan.types import Context +from . import changes + + +def dashboard_activity_stream( + user_id: str, + filter_type: Optional[str] = None, + filter_id: Optional[str] = None, + offset: int = 0, + limit: int = 0, + before: Optional[datetime.datetime] = None, + after: Optional[datetime.datetime] = None, +) -> list[dict[str, Any]]: + """Return the dashboard activity stream of the current user. + + :param user_id: the id of the user + :type user_id: string + + :param filter_type: the type of thing to filter by + :type filter_type: string + + :param filter_id: the id of item to filter by + :type filter_id: string + + :returns: an activity stream as an HTML snippet + :rtype: string + + """ + context = cast(Context, {"user": tk.g.user}) + if filter_type: + action_functions = { + "dataset": "package_activity_list", + "user": "user_activity_list", + "group": "group_activity_list", + "organization": "organization_activity_list", + } + action_function = tk.get_action(action_functions[filter_type]) + return action_function( + context, { + "id": filter_id, + "limit": limit, + "offset": offset, + "before": before, + "after": after + }) + else: + return tk.get_action("dashboard_activity_list")( + context, { + "offset": offset, + "limit": limit, + "before": before, + "after": after + } + ) + + +def recently_changed_packages_activity_stream( + limit: Optional[int] = None, +) -> list[dict[str, Any]]: + if limit: + data_dict = {"limit": limit} + else: + data_dict = {} + context = cast( + Context, {"model": model, "session": model.Session, "user": tk.g.user} + ) + return tk.get_action("recently_changed_packages_activity_list")( + context, data_dict + ) + + +def new_activities() -> Optional[int]: + """Return the number of activities for the current user. + + See :func:`logic.action.get.dashboard_new_activities_count` for more + details. + + """ + if not tk.g.userobj: + return None + action = tk.get_action("dashboard_new_activities_count") + return action({}, {}) + + +def activity_list_select( + pkg_activity_list: list[dict[str, Any]], current_activity_id: str +) -> list[Markup]: + """ + Builds an HTML formatted list of options for the select lists + on the "Changes" summary page. + """ + select_list = [] + template = jinja2.Template( + '', + autoescape=True, + ) + for activity in pkg_activity_list: + entry = tk.h.render_datetime( + activity["timestamp"], with_hours=True, with_seconds=True + ) + select_list.append( + Markup( + template.render( + activity_id=activity["id"], + timestamp=entry, + selected="selected" + if activity["id"] == current_activity_id + else "", + ) + ) + ) + + return select_list + + +def compare_pkg_dicts( + old: dict[str, Any], new: dict[str, Any], old_activity_id: str +) -> list[dict[str, Any]]: + """ + Takes two package dictionaries that represent consecutive versions of + the same dataset and returns a list of detailed & formatted summaries of + the changes between the two versions. old and new are the two package + dictionaries. The function assumes that both dictionaries will have + all of the default package dictionary keys, and also checks for fields + added by extensions and extra fields added by the user in the web + interface. + + Returns a list of dictionaries, each of which corresponds to a change + to the dataset made in this revision. The dictionaries each contain a + string indicating the type of change made as well as other data necessary + to form a detailed summary of the change. + """ + + change_list: list[dict[str, Any]] = [] + + changes.check_metadata_changes(change_list, old, new) + + changes.check_resource_changes(change_list, old, new, old_activity_id) + + # if the dataset was updated but none of the fields we check were changed, + # display a message stating that + if len(change_list) == 0: + change_list.append({"type": "no_change"}) + + return change_list + + +def compare_group_dicts( + old: dict[str, Any], new: dict[str, Any], old_activity_id: str +): + """ + Takes two package dictionaries that represent consecutive versions of + the same organization and returns a list of detailed & formatted summaries + of the changes between the two versions. old and new are the two package + dictionaries. The function assumes that both dictionaries will have + all of the default package dictionary keys, and also checks for fields + added by extensions and extra fields added by the user in the web + interface. + + Returns a list of dictionaries, each of which corresponds to a change + to the dataset made in this revision. The dictionaries each contain a + string indicating the type of change made as well as other data necessary + to form a detailed summary of the change. + """ + change_list: list[dict[str, Any]] = [] + + changes.check_metadata_org_changes(change_list, old, new) + + # if the organization was updated but none of the fields we check + # were changed, display a message stating that + if len(change_list) == 0: + change_list.append({"type": "no_change"}) + + return change_list + + +def activity_show_email_notifications() -> bool: + return tk.config.get("ckan.activity_streams_email_notifications") diff --git a/src/ckanext-d4science_theme/ckanext/activity/logic/__init__.py b/src/ckanext-d4science_theme/ckanext/activity/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/activity/logic/action.py b/src/ckanext-d4science_theme/ckanext/activity/logic/action.py new file mode 100644 index 0000000..a42a1c8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/logic/action.py @@ -0,0 +1,647 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import logging +import datetime +import json +from typing import Any, Optional + +import ckan.plugins.toolkit as tk + +from ckan.logic import validate +from ckan.types import Context, DataDict, ActionResult +import ckanext.activity.email_notifications as email_notifications + +from . import schema +from ..model import activity as model_activity, activity_dict_save + +log = logging.getLogger(__name__) + + +def send_email_notifications( + context: Context, data_dict: DataDict +) -> ActionResult.SendEmailNotifications: + """Send any pending activity stream notification emails to users. + + You must provide a sysadmin's API key/token in the Authorization header of + the request, or call this action from the command-line via a `ckan notify + send_emails ...` command. + + """ + tk.check_access("send_email_notifications", context, data_dict) + + if not tk.config.get("ckan.activity_streams_email_notifications"): + raise tk.ValidationError( + { + "message": ( + "ckan.activity_streams_email_notifications" + " is not enabled in config" + ) + } + ) + + email_notifications.get_and_send_notifications_for_all_users() + + +def dashboard_mark_activities_old( + context: Context, data_dict: DataDict +) -> ActionResult.DashboardMarkActivitiesOld: + """Mark all the authorized user's new dashboard activities as old. + + This will reset + :py:func:`~ckan.logic.action.get.dashboard_new_activities_count` to 0. + + """ + tk.check_access("dashboard_mark_activities_old", context, data_dict) + model = context["model"] + user_obj = model.User.get(context["user"]) + assert user_obj + user_id = user_obj.id + dashboard = model.Dashboard.get(user_id) + if dashboard: + dashboard.activity_stream_last_viewed = datetime.datetime.utcnow() + if not context.get("defer_commit"): + model.repo.commit() + + +def activity_create( + context: Context, data_dict: DataDict +) -> Optional[dict[str, Any]]: + """Create a new activity stream activity. + + You must be a sysadmin to create new activities. + + :param user_id: the name or id of the user who carried out the activity, + e.g. ``'seanh'`` + :type user_id: string + :param object_id: the name or id of the object of the activity, e.g. + ``'my_dataset'`` + :param activity_type: the type of the activity, this must be an activity + type that CKAN knows how to render, e.g. ``'new package'``, + ``'changed user'``, ``'deleted group'`` etc. + :type activity_type: string + :param data: any additional data about the activity + :type data: dictionary + + :returns: the newly created activity + :rtype: dictionary + + """ + + tk.check_access("activity_create", context, data_dict) + + if not tk.config.get("ckan.activity_streams_enabled"): + return + + model = context["model"] + + # Any revision_id that the caller attempts to pass in the activity_dict is + # ignored and removed here. + if "revision_id" in data_dict: + del data_dict["revision_id"] + + sch = context.get("schema") or schema.default_create_activity_schema() + + data, errors = tk.navl_validate(data_dict, sch, context) + if errors: + raise tk.ValidationError(errors) + + activity = activity_dict_save(data, context) + + if not context.get("defer_commit"): + model.repo.commit() + + log.debug("Created '%s' activity" % activity.activity_type) + return model_activity.activity_dictize(activity, context) + + +@validate(schema.default_activity_list_schema) +@tk.side_effect_free +def user_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return a user's public activity stream. + + You must be authorized to view the user's profile. + + + :param id: the id or name of the user + :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + :param after: After timestamp + (optional, default: ``None``) + :type after: int, str + :param before: Before timestamp + (optional, default: ``None``) + :type before: int, str + + :rtype: list of dictionaries + + """ + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + tk.check_access("user_activity_list", context, data_dict) + + model = context["model"] + + user_ref = data_dict.get("id") # May be user name or id. + user = model.User.get(user_ref) + if user is None: + raise tk.ObjectNotFound() + + offset = data_dict.get("offset", 0) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + after = data_dict.get("after") + before = data_dict.get("before") + + activity_objects = model_activity.user_activity_list( + user.id, + limit=limit, + offset=offset, + after=after, + before=before, + ) + + return model_activity.activity_list_dictize(activity_objects, context) + + +@validate(schema.default_activity_list_schema) +@tk.side_effect_free +def package_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return a package's activity stream (not including detail) + + You must be authorized to view the package. + + :param id: the id or name of the package + :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + :param after: After timestamp + (optional, default: ``None``) + :type after: int, str + :param before: Before timestamp + (optional, default: ``None``) + :type before: int, str + :param include_hidden_activity: whether to include 'hidden' activity, which + is not shown in the Activity Stream page. Hidden activity includes + activity done by the site_user, such as harvests, which are not shown + in the activity stream because they can be too numerous, or activity by + other users specified in config option `ckan.hide_activity_from_users`. + NB Only sysadmins may set include_hidden_activity to true. + (default: false) + :type include_hidden_activity: bool + :param activity_types: A list of activity types to include in the response + :type activity_types: list + + :param exclude_activity_types: A list of activity types to exclude from the + response + :type exclude_activity_types: list + + :rtype: list of dictionaries + + """ + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + include_hidden_activity = data_dict.get("include_hidden_activity", False) + activity_types = data_dict.pop("activity_types", None) + exclude_activity_types = data_dict.pop("exclude_activity_types", None) + + if activity_types is not None and exclude_activity_types is not None: + raise tk.ValidationError( + { + "activity_types": [ + "Cannot be used together with `exclude_activity_types" + ] + } + ) + + tk.check_access("package_activity_list", context, data_dict) + + model = context["model"] + + package_ref = data_dict.get("id") # May be name or ID. + package = model.Package.get(package_ref) + if package is None: + raise tk.ObjectNotFound() + + offset = int(data_dict.get("offset", 0)) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + after = data_dict.get("after") + before = data_dict.get("before") + + activity_objects = model_activity.package_activity_list( + package.id, + limit=limit, + offset=offset, + after=after, + before=before, + include_hidden_activity=include_hidden_activity, + activity_types=activity_types, + exclude_activity_types=exclude_activity_types, + ) + + return model_activity.activity_list_dictize(activity_objects, context) + + +@validate(schema.default_activity_list_schema) +@tk.side_effect_free +def group_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return a group's activity stream. + + You must be authorized to view the group. + + :param id: the id or name of the group + :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + :param include_hidden_activity: whether to include 'hidden' activity, which + is not shown in the Activity Stream page. Hidden activity includes + activity done by the site_user, such as harvests, which are not shown + in the activity stream because they can be too numerous, or activity by + other users specified in config option `ckan.hide_activity_from_users`. + NB Only sysadmins may set include_hidden_activity to true. + (default: false) + :type include_hidden_activity: bool + + :rtype: list of dictionaries + + """ + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + data_dict = dict(data_dict, include_data=False) + include_hidden_activity = data_dict.get("include_hidden_activity", False) + activity_types = data_dict.pop("activity_types", None) + tk.check_access("group_activity_list", context, data_dict) + + group_id = data_dict.get("id") + offset = data_dict.get("offset", 0) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + + # Convert group_id (could be id or name) into id. + group_show = tk.get_action("group_show") + group_id = group_show(context, {"id": group_id})["id"] + + after = data_dict.get("after") + before = data_dict.get("before") + + activity_objects = model_activity.group_activity_list( + group_id, + limit=limit, + offset=offset, + after=after, + before=before, + include_hidden_activity=include_hidden_activity, + activity_types=activity_types + ) + + return model_activity.activity_list_dictize(activity_objects, context) + + +@validate(schema.default_activity_list_schema) +@tk.side_effect_free +def organization_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return a organization's activity stream. + + :param id: the id or name of the organization + :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + :param include_hidden_activity: whether to include 'hidden' activity, which + is not shown in the Activity Stream page. Hidden activity includes + activity done by the site_user, such as harvests, which are not shown + in the activity stream because they can be too numerous, or activity by + other users specified in config option `ckan.hide_activity_from_users`. + NB Only sysadmins may set include_hidden_activity to true. + (default: false) + :type include_hidden_activity: bool + + :rtype: list of dictionaries + + """ + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + include_hidden_activity = data_dict.get("include_hidden_activity", False) + tk.check_access("organization_activity_list", context, data_dict) + + org_id = data_dict.get("id") + offset = data_dict.get("offset", 0) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + activity_types = data_dict.pop("activity_types", None) + + # Convert org_id (could be id or name) into id. + org_show = tk.get_action("organization_show") + org_id = org_show(context, {"id": org_id})["id"] + + after = data_dict.get("after") + before = data_dict.get("before") + + activity_objects = model_activity.organization_activity_list( + org_id, + limit=limit, + offset=offset, + after=after, + before=before, + include_hidden_activity=include_hidden_activity, + activity_types=activity_types + ) + + return model_activity.activity_list_dictize(activity_objects, context) + + +@validate(schema.default_dashboard_activity_list_schema) +@tk.side_effect_free +def recently_changed_packages_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return the activity stream of all recently added or changed packages. + + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + + :rtype: list of dictionaries + + """ + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + offset = data_dict.get("offset", 0) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + + activity_objects = model_activity.recently_changed_packages_activity_list( + limit=limit, offset=offset + ) + + return model_activity.activity_list_dictize(activity_objects, context) + + +@validate(schema.default_dashboard_activity_list_schema) +@tk.side_effect_free +def dashboard_activity_list( + context: Context, data_dict: DataDict +) -> list[dict[str, Any]]: + """Return the authorized (via login or API key) user's dashboard activity + stream. + + Unlike the activity dictionaries returned by other ``*_activity_list`` + actions, these activity dictionaries have an extra boolean value with key + ``is_new`` that tells you whether the activity happened since the user last + viewed her dashboard (``'is_new': True``) or not (``'is_new': False``). + + The user's own activities are always marked ``'is_new': False``. + + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int + + :rtype: list of activity dictionaries + + """ + tk.check_access("dashboard_activity_list", context, data_dict) + + model = context["model"] + user_obj = model.User.get(context["user"]) + assert user_obj + user_id = user_obj.id + offset = data_dict.get("offset", 0) + limit = data_dict["limit"] # defaulted, limited & made an int by schema + before = data_dict.get("before") + after = data_dict.get("after") + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + activity_objects = model_activity.dashboard_activity_list( + user_id, limit=limit, offset=offset, before=before, after=after + ) + + activity_dicts = model_activity.activity_list_dictize( + activity_objects, context + ) + + # Mark the new (not yet seen by user) activities. + strptime = datetime.datetime.strptime + fmt = "%Y-%m-%dT%H:%M:%S.%f" + dashboard = model.Dashboard.get(user_id) + last_viewed = None + if dashboard: + last_viewed = dashboard.activity_stream_last_viewed + for activity in activity_dicts: + if activity["user_id"] == user_id: + # Never mark the user's own activities as new. + activity["is_new"] = False + elif last_viewed: + activity["is_new"] = ( + strptime(activity["timestamp"], fmt) > last_viewed + ) + + return activity_dicts + + +@tk.side_effect_free +def dashboard_new_activities_count( + context: Context, data_dict: DataDict +) -> ActionResult.DashboardNewActivitiesCount: + """Return the number of new activities in the user's dashboard. + + Return the number of new activities in the authorized user's dashboard + activity stream. + + Activities from the user herself are not counted by this function even + though they appear in the dashboard (users don't want to be notified about + things they did themselves). + + :rtype: int + + """ + tk.check_access("dashboard_new_activities_count", context, data_dict) + activities = tk.get_action("dashboard_activity_list")(context, data_dict) + return len([activity for activity in activities if activity["is_new"]]) + + +@tk.side_effect_free +def activity_show(context: Context, data_dict: DataDict) -> dict[str, Any]: + """Show details of an item of 'activity' (part of the activity stream). + + :param id: the id of the activity + :type id: string + + :rtype: dictionary + """ + model = context["model"] + activity_id = tk.get_or_bust(data_dict, "id") + + activity = model.Session.query(model_activity.Activity).get(activity_id) + if activity is None: + raise tk.ObjectNotFound() + context["activity"] = activity + + tk.check_access("activity_show", context, data_dict) + + activity = model_activity.activity_dictize(activity, context) + return activity + + +@tk.side_effect_free +def activity_data_show( + context: Context, data_dict: DataDict +) -> dict[str, Any]: + """Show the data from an item of 'activity' (part of the activity + stream). + + For example for a package update this returns just the dataset dict but + none of the activity stream info of who and when the version was created. + + :param id: the id of the activity + :type id: string + :param object_type: 'package', 'user', 'group' or 'organization' + :type object_type: string + + :rtype: dictionary + """ + model = context["model"] + activity_id = tk.get_or_bust(data_dict, "id") + object_type = data_dict.get("object_type") + + activity = model.Session.query(model_activity.Activity).get(activity_id) + if activity is None: + raise tk.ObjectNotFound() + context["activity"] = activity + + tk.check_access("activity_data_show", context, data_dict) + + activity = model_activity.activity_dictize(activity, context) + try: + activity_data = activity["data"] + except KeyError: + raise tk.ObjectNotFound("Could not find data in the activity") + if object_type: + try: + activity_data = activity_data[object_type] + except KeyError: + raise tk.ObjectNotFound( + "Could not find that object_type in the activity" + ) + return activity_data + + +@tk.side_effect_free +def activity_diff(context: Context, data_dict: DataDict) -> dict[str, Any]: + """Returns a diff of the activity, compared to the previous version of the + object + + :param id: the id of the activity + :type id: string + :param object_type: 'package', 'user', 'group' or 'organization' + :type object_type: string + :param diff_type: 'unified', 'context', 'html' + :type diff_type: string + """ + import difflib + + model = context["model"] + activity_id = tk.get_or_bust(data_dict, "id") + object_type = tk.get_or_bust(data_dict, "object_type") + diff_type = data_dict.get("diff_type", "unified") + + tk.check_access("activity_diff", context, data_dict) + + activity = model.Session.query(model_activity.Activity).get(activity_id) + if activity is None: + raise tk.ObjectNotFound() + prev_activity = ( + model.Session.query(model_activity.Activity) + .filter_by(object_id=activity.object_id) + .filter(model_activity.Activity.timestamp < activity.timestamp) + .order_by( + # type_ignore_reason: incomplete SQLAlchemy types + model_activity.Activity.timestamp.desc() # type: ignore + ) + .first() + ) + if prev_activity is None: + raise tk.ObjectNotFound("Previous activity for this object not found") + activity_objs = [prev_activity, activity] + try: + objs = [ + activity_obj.data[object_type] for activity_obj in activity_objs + ] + except KeyError: + raise tk.ObjectNotFound("Could not find object in the activity data") + # convert each object dict to 'pprint'-style + # and split into lines to suit difflib + obj_lines = [ + json.dumps(obj, indent=2, sort_keys=True).split("\n") for obj in objs + ] + + # do the diff + if diff_type == "unified": + # type_ignore_reason: typechecker can't predict number of items + diff_generator = difflib.unified_diff(*obj_lines) # type: ignore + diff = "\n".join(line for line in diff_generator) + elif diff_type == "context": + # type_ignore_reason: typechecker can't predict number of items + diff_generator = difflib.context_diff(*obj_lines) # type: ignore + diff = "\n".join(line for line in diff_generator) + elif diff_type == "html": + # word-wrap lines. Otherwise you get scroll bars for most datasets. + import re + + for obj_index in (0, 1): + wrapped_obj_lines = [] + for line in obj_lines[obj_index]: + wrapped_obj_lines.extend(re.findall(r".{1,70}(?:\s+|$)", line)) + obj_lines[obj_index] = wrapped_obj_lines + # type_ignore_reason: typechecker can't predict number of items + diff = difflib.HtmlDiff().make_table(*obj_lines) # type: ignore + else: + raise tk.ValidationError({"message": "diff_type not recognized"}) + + activities = [ + model_activity.activity_dictize(activity_obj, context) + for activity_obj in activity_objs + ] + + return { + "diff": diff, + "activities": activities, + } diff --git a/src/ckanext-d4science_theme/ckanext/activity/logic/auth.py b/src/ckanext-d4science_theme/ckanext/activity/logic/auth.py new file mode 100644 index 0000000..0df9fd7 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/logic/auth.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from typing import Optional + +import ckan.authz as authz +import ckan.plugins.toolkit as tk +from ckan.types import Context, DataDict, AuthResult + +from ..model import Activity + + +def _get_activity_object( + context: Context, data_dict: Optional[DataDict] = None +) -> Activity: + try: + return context["activity"] + except KeyError: + if not data_dict: + data_dict = {} + id = data_dict.get("id", None) + if not id: + raise tk.ValidationError( + {"message": "Missing id, can not get Activity object"} + ) + obj = Activity.get(id) + if not obj: + raise tk.ObjectNotFound() + # Save in case we need this again during the request + context["activity"] = obj + return obj + + +def send_email_notifications( + context: Context, data_dict: DataDict +) -> AuthResult: + # Only sysadmins are authorized to send email notifications. + return {"success": False} + + +def activity_create(context: Context, data_dict: DataDict) -> AuthResult: + return {"success": False} + + +@tk.auth_allow_anonymous_access +def dashboard_activity_list( + context: Context, data_dict: DataDict +) -> AuthResult: + # FIXME: context['user'] could be an IP address but that case is not + # handled here. Maybe add an auth helper function like is_logged_in(). + if context.get("user"): + return {"success": True} + else: + return { + "success": False, + "msg": tk._("You must be logged in to access your dashboard."), + } + + +@tk.auth_allow_anonymous_access +def dashboard_new_activities_count( + context: Context, data_dict: DataDict +) -> AuthResult: + # FIXME: This should go through check_access() not call is_authorized() + # directly, but wait until 2939-orgs is merged before fixing this. + # This is so a better not authourized message can be sent. + return authz.is_authorized("dashboard_activity_list", context, data_dict) + + +@tk.auth_allow_anonymous_access +def activity_list(context: Context, data_dict: DataDict) -> AuthResult: + """ + :param id: the id or name of the object (e.g. package id) + :type id: string + :param object_type: The type of the object (e.g. 'package', 'organization', + 'group', 'user') + :type object_type: string + :param include_data: include the data field, containing a full object dict + (otherwise the data field is only returned with the object's title) + :type include_data: boolean + """ + if data_dict["object_type"] not in ( + "package", + "organization", + "group", + "user", + ): + return {"success": False, "msg": "object_type not recognized"} + is_public = authz.check_config_permission("public_activity_stream_detail") + if data_dict.get("include_data") and not is_public: + # The 'data' field of the activity is restricted to users who are + # allowed to edit the object + show_or_update = "update" + else: + # the activity for an object (i.e. the activity metadata) can be viewed + # if the user can see the object + show_or_update = "show" + action_on_which_to_base_auth = "{}_{}".format( + data_dict["object_type"], show_or_update + ) # e.g. 'package_update' + return authz.is_authorized( + action_on_which_to_base_auth, context, {"id": data_dict["id"]} + ) + + +@tk.auth_allow_anonymous_access +def user_activity_list(context: Context, data_dict: DataDict) -> AuthResult: + data_dict["object_type"] = "user" + # TODO: use authz.is_authorized in order to allow chained auth functions. + # TODO: fix the issue in other functions as well + return activity_list(context, data_dict) + + +@tk.auth_allow_anonymous_access +def package_activity_list(context: Context, data_dict: DataDict) -> AuthResult: + data_dict["object_type"] = "package" + return activity_list(context, data_dict) + + +@tk.auth_allow_anonymous_access +def group_activity_list(context: Context, data_dict: DataDict) -> AuthResult: + data_dict["object_type"] = "group" + return activity_list(context, data_dict) + + +@tk.auth_allow_anonymous_access +def organization_activity_list( + context: Context, data_dict: DataDict +) -> AuthResult: + data_dict["object_type"] = "organization" + return activity_list(context, data_dict) + + +@tk.auth_allow_anonymous_access +def activity_show(context: Context, data_dict: DataDict) -> AuthResult: + """ + :param id: the id of the activity + :type id: string + :param include_data: include the data field, containing a full object dict + (otherwise the data field is only returned with the object's title) + :type include_data: boolean + """ + activity = _get_activity_object(context, data_dict) + # NB it would be better to have recorded an activity_type against the + # activity + if "package" in activity.activity_type: + object_type = "package" + else: + return {"success": False, "msg": "object_type not recognized"} + return activity_list( + context, + { + "id": activity.object_id, + "include_data": data_dict["include_data"], + "object_type": object_type, + }, + ) + + +@tk.auth_allow_anonymous_access +def activity_data_show(context: Context, data_dict: DataDict) -> AuthResult: + """ + :param id: the id of the activity + :type id: string + """ + data_dict["include_data"] = True + return activity_show(context, data_dict) + + +@tk.auth_allow_anonymous_access +def activity_diff(context: Context, data_dict: DataDict) -> AuthResult: + """ + :param id: the id of the activity + :type id: string + """ + data_dict["include_data"] = True + return activity_show(context, data_dict) + + +def dashboard_mark_activities_old( + context: Context, data_dict: DataDict +) -> AuthResult: + return authz.is_authorized("dashboard_activity_list", context, data_dict) diff --git a/src/ckanext-d4science_theme/ckanext/activity/logic/schema.py b/src/ckanext-d4science_theme/ckanext/activity/logic/schema.py new file mode 100644 index 0000000..e01f52f --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/logic/schema.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from typing import cast + +from ckan.logic.schema import validator_args, default_pagination_schema +from ckan.types import Schema, Validator, ValidatorFactory + + +@validator_args +def default_create_activity_schema( + ignore: Validator, + not_missing: Validator, + not_empty: Validator, + unicode_safe: Validator, + convert_user_name_or_id_to_id: Validator, + object_id_validator: Validator, + activity_type_exists: Validator, + ignore_empty: Validator, + ignore_missing: Validator, +): + return cast( + Schema, + { + "id": [ignore], + "timestamp": [ignore], + "user_id": [ + not_missing, + not_empty, + unicode_safe, + convert_user_name_or_id_to_id, + ], + "object_id": [ + not_missing, + not_empty, + unicode_safe, + object_id_validator, + ], + "activity_type": [ + not_missing, + not_empty, + unicode_safe, + activity_type_exists, + ], + "data": [ignore_empty, ignore_missing], + }, + ) + + +@validator_args +def default_dashboard_activity_list_schema( + configured_default: ValidatorFactory, + natural_number_validator: Validator, + limit_to_configured_maximum: ValidatorFactory, + ignore_missing: Validator, + datetime_from_timestamp_validator: Validator, + +): + schema = default_pagination_schema() + schema["limit"] = [ + configured_default("ckan.activity_list_limit", 31), + natural_number_validator, + limit_to_configured_maximum("ckan.activity_list_limit_max", 100), + ] + schema["before"] = [ignore_missing, datetime_from_timestamp_validator] + schema["after"] = [ignore_missing, datetime_from_timestamp_validator] + return schema + + +@validator_args +def default_activity_list_schema( + not_missing: Validator, + unicode_safe: Validator, + configured_default: ValidatorFactory, + natural_number_validator: Validator, + limit_to_configured_maximum: ValidatorFactory, + ignore_missing: Validator, + boolean_validator: Validator, + ignore_not_sysadmin: Validator, + list_of_strings: Validator, + datetime_from_timestamp_validator: Validator, +): + + schema = default_pagination_schema() + schema["id"] = [not_missing, unicode_safe] + schema["limit"] = [ + configured_default("ckan.activity_list_limit", 31), + natural_number_validator, + limit_to_configured_maximum("ckan.activity_list_limit_max", 100), + ] + schema["include_hidden_activity"] = [ + ignore_missing, + ignore_not_sysadmin, + boolean_validator, + ] + schema["activity_types"] = [ignore_missing, list_of_strings] + schema["exclude_activity_types"] = [ignore_missing, list_of_strings] + schema["before"] = [ignore_missing, datetime_from_timestamp_validator] + schema["after"] = [ignore_missing, datetime_from_timestamp_validator] + + return schema diff --git a/src/ckanext-d4science_theme/ckanext/activity/logic/validators.py b/src/ckanext-d4science_theme/ckanext/activity/logic/validators.py new file mode 100644 index 0000000..ac3af06 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/logic/validators.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations +from typing import Any, cast + +import ckan.plugins.toolkit as tk + +from ckan.types import ( + FlattenDataDict, + FlattenKey, + Context, + FlattenErrorDict, + ContextValidator, +) + + +def activity_type_exists(activity_type: Any) -> Any: + """Raises Invalid if there is no registered activity renderer for the + given activity_type. Otherwise returns the given activity_type. + + This just uses object_id_validators as a lookup. + very safe. + + """ + if activity_type in object_id_validators: + return activity_type + else: + raise tk.Invalid("%s: %s" % (tk._("Not found"), tk._("Activity type"))) + + +VALIDATORS_PACKAGE_ACTIVITY_TYPES = { + "new package": "package_id_exists", + "changed package": "package_id_exists", + "deleted package": "package_id_exists", + "follow dataset": "package_id_exists", +} + +VALIDATORS_USER_ACTIVITY_TYPES = { + "new user": "user_id_exists", + "changed user": "user_id_exists", + "follow user": "user_id_exists", +} + +VALIDATORS_GROUP_ACTIVITY_TYPES = { + "new group": "group_id_exists", + "changed group": "group_id_exists", + "deleted group": "group_id_exists", + "follow group": "group_id_exists", +} + +VALIDATORS_ORGANIZATION_ACTIVITY_TYPES = { + "new organization": "group_id_exists", + "changed organization": "group_id_exists", + "deleted organization": "group_id_exists", + "follow organization": "group_id_exists", +} + +# A dictionary mapping activity_type values from activity dicts to functions +# for validating the object_id values from those same activity dicts. +object_id_validators = { + **VALIDATORS_PACKAGE_ACTIVITY_TYPES, + **VALIDATORS_USER_ACTIVITY_TYPES, + **VALIDATORS_GROUP_ACTIVITY_TYPES, + **VALIDATORS_ORGANIZATION_ACTIVITY_TYPES +} + + +def object_id_validator( + key: FlattenKey, + activity_dict: FlattenDataDict, + errors: FlattenErrorDict, + context: Context, +) -> Any: + """Validate the 'object_id' value of an activity_dict. + + Uses the object_id_validators dict (above) to find and call an 'object_id' + validator function for the given activity_dict's 'activity_type' value. + + Raises Invalid if the model given in context contains no object of the + correct type (according to the 'activity_type' value of the activity_dict) + with the given ID. + + Raises Invalid if there is no object_id_validator for the activity_dict's + 'activity_type' value. + + """ + activity_type = activity_dict[("activity_type",)] + if activity_type in object_id_validators: + object_id = activity_dict[("object_id",)] + name = object_id_validators[activity_type] + validator = cast(ContextValidator, tk.get_validator(name)) + return validator(object_id, context) + else: + raise tk.Invalid( + 'There is no object_id validator for activity type "%s"' + % activity_type + ) diff --git a/src/ckanext-d4science_theme/ckanext/activity/model/__init__.py b/src/ckanext-d4science_theme/ckanext/activity/model/__init__.py new file mode 100644 index 0000000..82c3c03 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/model/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations +from typing import Any + +from ckan.types import Context + +from .activity import Activity + + +__all__ = ["Activity"] + + +def activity_dict_save( + activity_dict: dict[str, Any], context: Context +) -> "Activity": + + session = context["session"] + user_id = activity_dict["user_id"] + object_id = activity_dict["object_id"] + activity_type = activity_dict["activity_type"] + if "data" in activity_dict: + data = activity_dict["data"] + else: + data = None + activity_obj = Activity(user_id, object_id, activity_type, data) + session.add(activity_obj) + return activity_obj diff --git a/src/ckanext-d4science_theme/ckanext/activity/model/activity.py b/src/ckanext-d4science_theme/ckanext/activity/model/activity.py new file mode 100644 index 0000000..50ed980 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/model/activity.py @@ -0,0 +1,791 @@ +# encoding: utf-8 +from __future__ import annotations + +import datetime +from typing import Any, Optional, Type, TypeVar, cast +from typing_extensions import TypeAlias + +from sqlalchemy.orm import relationship, backref +from sqlalchemy import ( + types, + Column, + ForeignKey, + or_, + and_, + union_all, + text, +) + +from ckan.common import config +import ckan.model as model +import ckan.model.meta as meta +import ckan.model.domain_object as domain_object +import ckan.model.types as _types +from ckan.model.base import BaseModel +from ckan.lib.dictization import table_dictize + +from ckan.types import Context, Query # noqa + + +__all__ = ["Activity", "ActivityDetail"] + +TActivityDetail = TypeVar("TActivityDetail", bound="ActivityDetail") +QActivity: TypeAlias = "Query[Activity]" + + +class Activity(domain_object.DomainObject, BaseModel): # type: ignore + __tablename__ = "activity" + # the line below handles cases when activity table was already loaded into + # metadata state(via stats extension). Can be removed if stats stop using + # Table object. + __table_args__ = {"extend_existing": True} + + id = Column( + "id", types.UnicodeText, primary_key=True, default=_types.make_uuid + ) + timestamp = Column("timestamp", types.DateTime) + user_id = Column("user_id", types.UnicodeText) + object_id = Column("object_id", types.UnicodeText) + # legacy revision_id values are used by migrate_package_activity.py + revision_id = Column("revision_id", types.UnicodeText) + activity_type = Column("activity_type", types.UnicodeText) + data = Column("data", _types.JsonDictType) + + activity_detail: "ActivityDetail" + + def __init__( + self, + user_id: str, + object_id: str, + activity_type: str, + data: Optional[dict[str, Any]] = None, + ) -> None: + self.id = _types.make_uuid() + self.timestamp = datetime.datetime.utcnow() + self.user_id = user_id + self.object_id = object_id + self.activity_type = activity_type + if data is None: + self.data = {} + else: + self.data = data + + @classmethod + def get(cls, id: str) -> Optional["Activity"]: + """Returns an Activity object referenced by its id.""" + if not id: + return None + + return meta.Session.query(cls).get(id) + + @classmethod + def activity_stream_item( + cls, pkg: model.Package, activity_type: str, user_id: str + ) -> Optional["Activity"]: + import ckan.model + import ckan.logic + + assert activity_type in ("new", "changed"), str(activity_type) + + # Handle 'deleted' objects. + # When the user marks a package as deleted this comes through here as + # a 'changed' package activity. We detect this and change it to a + # 'deleted' activity. + if activity_type == "changed" and pkg.state == "deleted": + if ( + meta.Session.query(cls) + .filter_by(object_id=pkg.id, activity_type="deleted") + .all() + ): + # A 'deleted' activity for this object has already been emitted + # FIXME: What if the object was deleted and then activated + # again? + return None + else: + # Emit a 'deleted' activity for this object. + activity_type = "deleted" + + try: + # We save the entire rendered package dict so we can support + # viewing the past packages from the activity feed. + dictized_package = ckan.logic.get_action("package_show")( + cast( + Context, + { + "model": ckan.model, + "session": ckan.model.Session, + # avoid ckanext-multilingual translating it + "for_view": False, + "ignore_auth": True, + }, + ), + {"id": pkg.id, "include_tracking": False}, + ) + except ckan.logic.NotFound: + # This happens if this package is being purged and therefore has no + # current revision. + # TODO: Purge all related activity stream items when a model object + # is purged. + return None + + actor = meta.Session.query(ckan.model.User).get(user_id) + + return cls( + user_id, + pkg.id, + "%s package" % activity_type, + { + "package": dictized_package, + # We keep the acting user name around so that actions can be + # properly displayed even if the user is deleted in the future. + "actor": actor.name if actor else None, + }, + ) + + +def activity_dictize(activity: Activity, context: Context) -> dict[str, Any]: + return table_dictize(activity, context) + + +def activity_list_dictize( + activity_list: list[Activity], context: Context +) -> list[dict[str, Any]]: + return [activity_dictize(activity, context) for activity in activity_list] + + +# deprecated +class ActivityDetail(domain_object.DomainObject): + __tablename__ = "activity_detail" + id = Column( + "id", types.UnicodeText, primary_key=True, default=_types.make_uuid + ) + activity_id = Column( + "activity_id", types.UnicodeText, ForeignKey("activity.id") + ) + object_id = Column("object_id", types.UnicodeText) + object_type = Column("object_type", types.UnicodeText) + activity_type = Column("activity_type", types.UnicodeText) + data = Column("data", _types.JsonDictType) + + activity = relationship( + Activity, + backref=backref("activity_detail", cascade="all, delete-orphan"), + ) + + def __init__( + self, + activity_id: str, + object_id: str, + object_type: str, + activity_type: str, + data: Optional[dict[str, Any]] = None, + ) -> None: + self.activity_id = activity_id + self.object_id = object_id + self.object_type = object_type + self.activity_type = activity_type + if data is None: + self.data = {} + else: + self.data = data + + @classmethod + def by_activity_id( + cls: Type[TActivityDetail], activity_id: str + ) -> list["TActivityDetail"]: + return ( + model.Session.query(cls).filter_by(activity_id=activity_id).all() + ) + + +def _activities_limit( + q: QActivity, + limit: int, + offset: Optional[int] = None, + revese_order: Optional[bool] = False, +) -> QActivity: + """ + Return an SQLAlchemy query for all activities at an offset with a limit. + + revese_order: + if we want the last activities before a date, we must reverse the + order before limiting. + """ + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + return q + + +def _activities_union_all(*qlist: QActivity) -> QActivity: + """ + Return union of two or more activity queries sorted by timestamp, + and remove duplicates + """ + q: QActivity = ( + model.Session.query(Activity) + .select_entity_from(union_all(*[q.subquery().select() for q in qlist])) + .distinct(Activity.timestamp) + ) + return q + + +def _activities_from_user_query(user_id: str) -> QActivity: + """Return an SQLAlchemy query for all activities from user_id.""" + q = model.Session.query(Activity) + q = q.filter(Activity.user_id == user_id) + return q + + +def _activities_about_user_query(user_id: str) -> QActivity: + """Return an SQLAlchemy query for all activities about user_id.""" + q = model.Session.query(Activity) + q = q.filter(Activity.object_id == user_id) + return q + + +def _user_activity_query(user_id: str, limit: int) -> QActivity: + """Return an SQLAlchemy query for all activities from or about user_id.""" + q1 = _activities_limit(_activities_from_user_query(user_id), limit) + q2 = _activities_limit(_activities_about_user_query(user_id), limit) + return _activities_union_all(q1, q2) + + +def user_activity_list( + user_id: str, + limit: int, + offset: int, + after: Optional[datetime.datetime] = None, + before: Optional[datetime.datetime] = None, +) -> list[Activity]: + """Return user_id's public activity stream. + + Return a list of all activities from or about the given user, i.e. where + the given user is the subject or object of the activity, e.g.: + + "{USER} created the dataset {DATASET}" + "{OTHER_USER} started following {USER}" + etc. + + """ + q1 = _activities_from_user_query(user_id) + q2 = _activities_about_user_query(user_id) + + q = _activities_union_all(q1, q2) + + q = _filter_activitites_from_users(q) + + if after: + q = q.filter(Activity.timestamp > after) + if before: + q = q.filter(Activity.timestamp < before) + + # revert sort queries for "only before" queries + revese_order = after and not before + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + + results = q.all() + + # revert result if required + if revese_order: + results.reverse() + + return results + + +def _package_activity_query(package_id: str) -> QActivity: + """Return an SQLAlchemy query for all activities about package_id.""" + q = model.Session.query(Activity).filter_by(object_id=package_id) + return q + + +def package_activity_list( + package_id: str, + limit: int, + offset: Optional[int] = None, + after: Optional[datetime.datetime] = None, + before: Optional[datetime.datetime] = None, + include_hidden_activity: bool = False, + activity_types: Optional[list[str]] = None, + exclude_activity_types: Optional[list[str]] = None, +) -> list[Activity]: + """Return the given dataset (package)'s public activity stream. + + Returns all activities about the given dataset, i.e. where the given + dataset is the object of the activity, e.g.: + + "{USER} created the dataset {DATASET}" + "{USER} updated the dataset {DATASET}" + etc. + + """ + q = _package_activity_query(package_id) + + if not include_hidden_activity: + q = _filter_activitites_from_users(q) + + if activity_types: + q = _filter_activitites_from_type( + q, include=True, types=activity_types + ) + elif exclude_activity_types: + q = _filter_activitites_from_type( + q, include=False, types=exclude_activity_types + ) + + if after: + q = q.filter(Activity.timestamp > after) + if before: + q = q.filter(Activity.timestamp < before) + + # revert sort queries for "only before" queries + revese_order = after and not before + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + + results = q.all() + + # revert result if required + if revese_order: + results.reverse() + + return results + + +def _group_activity_query(group_id: str) -> QActivity: + """Return an SQLAlchemy query for all activities about group_id. + + Returns a query for all activities whose object is either the group itself + or one of the group's datasets. + + """ + group = model.Group.get(group_id) + if not group: + # Return a query with no results. + return model.Session.query(Activity).filter(text("0=1")) + + q: QActivity = ( + model.Session.query(Activity) + .outerjoin(model.Member, Activity.object_id == model.Member.table_id) + .outerjoin( + model.Package, + and_( + model.Package.id == model.Member.table_id, + model.Package.private == False, # noqa + ), + ) + .filter( + # We only care about activity either on the group itself or on + # packages within that group. FIXME: This means that activity that + # occured while a package belonged to a group but was then removed + # will not show up. This may not be desired but is consistent with + # legacy behaviour. + or_( + # active dataset in the group + and_( + model.Member.group_id == group_id, + model.Member.state == "active", + model.Package.state == "active", + ), + # deleted dataset in the group + and_( + model.Member.group_id == group_id, + model.Member.state == "deleted", + model.Package.state == "deleted", + ), + # (we want to avoid showing changes to an active dataset that + # was once in this group) + # activity the the group itself + Activity.object_id == group_id, + ) + ) + ) + + return q + + +def _organization_activity_query(org_id: str) -> QActivity: + """Return an SQLAlchemy query for all activities about org_id. + + Returns a query for all activities whose object is either the org itself + or one of the org's datasets. + + """ + org = model.Group.get(org_id) + if not org or not org.is_organization: + # Return a query with no results. + return model.Session.query(Activity).filter(text("0=1")) + + q: QActivity = ( + model.Session.query(Activity) + .outerjoin( + model.Package, + and_( + model.Package.id == Activity.object_id, + model.Package.private == False, # noqa + ), + ) + .filter( + # We only care about activity either on the the org itself or on + # packages within that org. + # FIXME: This means that activity that occured while a package + # belonged to a org but was then removed will not show up. This may + # not be desired but is consistent with legacy behaviour. + or_( + model.Package.owner_org == org_id, Activity.object_id == org_id + ) + ) + ) + + return q + + +def group_activity_list( + group_id: str, + limit: int, + offset: int, + after: Optional[datetime.datetime] = None, + before: Optional[datetime.datetime] = None, + include_hidden_activity: bool = False, + activity_types: Optional[list[str]] = None +) -> list[Activity]: + + """Return the given group's public activity stream. + + Returns activities where the given group or one of its datasets is the + object of the activity, e.g.: + + "{USER} updated the group {GROUP}" + "{USER} updated the dataset {DATASET}" + etc. + + """ + q = _group_activity_query(group_id) + + if not include_hidden_activity: + q = _filter_activitites_from_users(q) + + if activity_types: + q = _filter_activitites_from_type( + q, include=True, types=activity_types + ) + + if after: + q = q.filter(Activity.timestamp > after) + if before: + q = q.filter(Activity.timestamp < before) + + # revert sort queries for "only before" queries + revese_order = after and not before + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + + results = q.all() + + # revert result if required + if revese_order: + results.reverse() + + return results + + +def organization_activity_list( + group_id: str, + limit: int, + offset: int, + after: Optional[datetime.datetime] = None, + before: Optional[datetime.datetime] = None, + include_hidden_activity: bool = False, + activity_types: Optional[list[str]] = None +) -> list[Activity]: + """Return the given org's public activity stream. + + Returns activities where the given org or one of its datasets is the + object of the activity, e.g.: + + "{USER} updated the organization {ORG}" + "{USER} updated the dataset {DATASET}" + etc. + + """ + q = _organization_activity_query(group_id) + + if not include_hidden_activity: + q = _filter_activitites_from_users(q) + + if activity_types: + q = _filter_activitites_from_type( + q, include=True, types=activity_types + ) + + if after: + q = q.filter(Activity.timestamp > after) + if before: + q = q.filter(Activity.timestamp < before) + + # revert sort queries for "only before" queries + revese_order = after and not before + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + + results = q.all() + + # revert result if required + if revese_order: + results.reverse() + + return results + + +def _activities_from_users_followed_by_user_query( + user_id: str, limit: int +) -> QActivity: + """Return a query for all activities from users that user_id follows.""" + + # Get a list of the users that the given user is following. + follower_objects = model.UserFollowingUser.followee_list(user_id) + if not follower_objects: + # Return a query with no results. + return model.Session.query(Activity).filter(text("0=1")) + + return _activities_union_all( + *[ + _user_activity_query(follower.object_id, limit) + for follower in follower_objects + ] + ) + + +def _activities_from_datasets_followed_by_user_query( + user_id: str, limit: int +) -> QActivity: + """Return a query for all activities from datasets that user_id follows.""" + # Get a list of the datasets that the user is following. + follower_objects = model.UserFollowingDataset.followee_list(user_id) + if not follower_objects: + # Return a query with no results. + return model.Session.query(Activity).filter(text("0=1")) + + return _activities_union_all( + *[ + _activities_limit( + _package_activity_query(follower.object_id), limit + ) + for follower in follower_objects + ] + ) + + +def _activities_from_groups_followed_by_user_query( + user_id: str, limit: int +) -> QActivity: + """Return a query for all activities about groups the given user follows. + + Return a query for all activities about the groups the given user follows, + or about any of the group's datasets. This is the union of + _group_activity_query(group_id) for each of the groups the user follows. + + """ + # Get a list of the group's that the user is following. + follower_objects = model.UserFollowingGroup.followee_list(user_id) + if not follower_objects: + # Return a query with no results. + return model.Session.query(Activity).filter(text("0=1")) + + return _activities_union_all( + *[ + _activities_limit(_group_activity_query(follower.object_id), limit) + for follower in follower_objects + ] + ) + + +def _activities_from_everything_followed_by_user_query( + user_id: str, limit: int = 0 +) -> QActivity: + """Return a query for all activities from everything user_id follows.""" + q1 = _activities_from_users_followed_by_user_query(user_id, limit) + q2 = _activities_from_datasets_followed_by_user_query(user_id, limit) + q3 = _activities_from_groups_followed_by_user_query(user_id, limit) + return _activities_union_all(q1, q2, q3) + + +def activities_from_everything_followed_by_user( + user_id: str, limit: int, offset: int +) -> list[Activity]: + """Return activities from everything that the given user is following. + + Returns all activities where the object of the activity is anything + (user, dataset, group...) that the given user is following. + + """ + q = _activities_from_everything_followed_by_user_query( + user_id, limit + offset + ) + return _activities_limit(q, limit, offset).all() + + +def _dashboard_activity_query(user_id: str, limit: int = 0) -> QActivity: + """Return an SQLAlchemy query for user_id's dashboard activity stream.""" + q1 = _user_activity_query(user_id, limit) + q2 = _activities_from_everything_followed_by_user_query(user_id, limit) + return _activities_union_all(q1, q2) + + +def dashboard_activity_list( + user_id: str, + limit: int, + offset: int, + before: Optional[datetime.datetime] = None, + after: Optional[datetime.datetime] = None, +) -> list[Activity]: + """Return the given user's dashboard activity stream. + + Returns activities from the user's public activity stream, plus + activities from everything that the user is following. + + This is the union of user_activity_list(user_id) and + activities_from_everything_followed_by_user(user_id). + + """ + q = _dashboard_activity_query(user_id) + + q = _filter_activitites_from_users(q) + + if after: + q = q.filter(Activity.timestamp > after) + if before: + q = q.filter(Activity.timestamp < before) + + # revert sort queries for "only before" queries + revese_order = after and not before + if revese_order: + q = q.order_by(Activity.timestamp) + else: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.order_by(Activity.timestamp.desc()) # type: ignore + + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + + results = q.all() + + # revert result if required + if revese_order: + results.reverse() + + return results + + +def _changed_packages_activity_query() -> QActivity: + """Return an SQLAlchemy query for all changed package activities. + + Return a query for all activities with activity_type '*package', e.g. + 'new_package', 'changed_package', 'deleted_package'. + + """ + q = model.Session.query(Activity) + q = q.filter(Activity.activity_type.endswith("package")) + return q + + +def recently_changed_packages_activity_list( + limit: int, offset: int +) -> list[Activity]: + """Return the site-wide stream of recently changed package activities. + + This activity stream includes recent 'new package', 'changed package' and + 'deleted package' activities for the whole site. + + """ + q = _changed_packages_activity_query() + + q = _filter_activitites_from_users(q) + + return _activities_limit(q, limit, offset).all() + + +def _filter_activitites_from_users(q: QActivity) -> QActivity: + """ + Adds a filter to an existing query object to avoid activities from users + defined in :ref:`ckan.hide_activity_from_users` (defaults to the site user) + """ + users_to_avoid = _activity_stream_get_filtered_users() + if users_to_avoid: + # type_ignore_reason: incomplete SQLAlchemy types + q = q.filter(Activity.user_id.notin_(users_to_avoid)) # type: ignore + + return q + + +def _filter_activitites_from_type( + q: QActivity, types: list[str], include: bool = True +): + """Adds a filter to an existing query object to include or exclude + (include=False) activities based on a list of types. + + """ + if include: + q = q.filter(Activity.activity_type.in_(types)) # type: ignore + else: + q = q.filter(Activity.activity_type.notin_(types)) # type: ignore + return q + + +def _activity_stream_get_filtered_users() -> list[str]: + """ + Get the list of users from the :ref:`ckan.hide_activity_from_users` config + option and return a list of their ids. If the config is not specified, + returns the id of the site user. + """ + users_list = config.get("ckan.hide_activity_from_users") + if not users_list: + from ckan.logic import get_action + + context: Context = {"ignore_auth": True} + site_user = get_action("get_site_user")(context, {}) + users_list = [site_user.get("name")] + + return model.User.user_ids_for_name_or_id(users_list) diff --git a/src/ckanext-d4science_theme/ckanext/activity/plugin.py b/src/ckanext-d4science_theme/ckanext/activity/plugin.py new file mode 100644 index 0000000..fe610c0 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/plugin.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from ckan.common import CKANConfig + +import ckan.plugins as p +import ckan.plugins.toolkit as tk + +from . import subscriptions + + +@tk.blanket.auth_functions +@tk.blanket.actions +@tk.blanket.helpers +@tk.blanket.blueprints +@tk.blanket.validators +class ActivityPlugin(p.SingletonPlugin): + p.implements(p.IConfigurer) + p.implements(p.ISignal) + + # IConfigurer + def update_config(self, config: CKANConfig): + tk.add_template_directory(config, "templates") + tk.add_public_directory(config, "public") + tk.add_resource("assets", "ckanext-activity") + + # ISignal + def get_signal_subscriptions(self): + return subscriptions.get_subscriptions() diff --git a/src/ckanext-d4science_theme/ckanext/activity/public/dotted.png b/src/ckanext-d4science_theme/ckanext/activity/public/dotted.png new file mode 100644 index 0000000..fa0ae80 Binary files /dev/null and b/src/ckanext-d4science_theme/ckanext/activity/public/dotted.png differ diff --git a/src/ckanext-d4science_theme/ckanext/activity/subscriptions.py b/src/ckanext-d4science_theme/ckanext/activity/subscriptions.py new file mode 100644 index 0000000..acdcc7a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/subscriptions.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations +import logging +from typing import Any + +import ckan.plugins.toolkit as tk +import ckan.lib.dictization as dictization + +from ckan import types +from .model import Activity + +log = logging.getLogger(__name__) + + +def get_subscriptions() -> types.SignalMapping: + return { + tk.signals.action_succeeded: [ + {"sender": "bulk_update_public", "receiver": bulk_changed}, + {"sender": "bulk_update_private", "receiver": bulk_changed}, + {"sender": "bulk_update_delete", "receiver": bulk_changed}, + {"sender": "package_create", "receiver": package_changed}, + {"sender": "package_update", "receiver": package_changed}, + {"sender": "package_delete", "receiver": package_changed}, + {"sender": "group_create", "receiver": group_or_org_changed}, + {"sender": "group_update", "receiver": group_or_org_changed}, + {"sender": "group_delete", "receiver": group_or_org_changed}, + { + "sender": "organization_create", + "receiver": group_or_org_changed, + }, + { + "sender": "organization_update", + "receiver": group_or_org_changed, + }, + { + "sender": "organization_delete", + "receiver": group_or_org_changed, + }, + {"sender": "user_create", "receiver": user_changed}, + {"sender": "user_update", "receiver": user_changed}, + ] + } + + +# action, context, data_dict, result +def bulk_changed(sender: str, **kwargs: Any): + for key in ("context", "data_dict"): + if key not in kwargs: + log.warning("Activity subscription ignored") + return + + context: types.Context = kwargs["context"] + datasets = kwargs["data_dict"].get("datasets") + model = context["model"] + + user = context["user"] + user_obj = model.User.get(user) + if user_obj: + user_id = user_obj.id + else: + user_id = "not logged in" + for dataset in datasets: + entity = model.Package.get(dataset) + assert entity + + activity = Activity.activity_stream_item(entity, "changed", user_id) + model.Session.add(activity) + + if not context.get("defer_commit"): + model.Session.commit() + + +# action, context, data_dict, result +def package_changed(sender: str, **kwargs: Any): + for key in ("result", "context", "data_dict"): + if key not in kwargs: + log.warning("Activity subscription ignored") + return + + type_ = "new" if sender == "package_create" else "changed" + + context: types.Context = kwargs["context"] + result: types.ActionResult.PackageUpdate = kwargs["result"] + data_dict = kwargs["data_dict"] + + if not result: + id_ = data_dict["id"] + elif isinstance(result, str): + id_ = result + else: + id_ = result["id"] + + pkg = context["model"].Package.get(id_) + assert pkg + + if pkg.private: + return + + user_obj = context["model"].User.get(context["user"]) + if user_obj: + user_id = user_obj.id + else: + user_id = "not logged in" + + activity = Activity.activity_stream_item(pkg, type_, user_id) + context["session"].add(activity) + if not context.get("defer_commit"): + context["session"].commit() + + +# action, context, data_dict, result +def group_or_org_changed(sender: str, **kwargs: Any): + for key in ("result", "context", "data_dict"): + if key not in kwargs: + log.warning("Activity subscription ignored") + return + + context: types.Context = kwargs["context"] + result: types.ActionResult.GroupUpdate = kwargs["result"] + data_dict = kwargs["data_dict"] + + group = context["model"].Group.get( + result["id"] if result else data_dict["id"] + ) + assert group + + type_, action = sender.split("_") + + user_obj = context["model"].User.get(context["user"]) + assert user_obj + + activity_dict: dict[str, Any] = { + "user_id": user_obj.id, + "object_id": group.id, + } + + if group.state == "deleted" or action == "delete": + activity_type = f"deleted {type_}" + elif action == "create": + activity_type = f"new {type_}" + else: + activity_type = f"changed {type_}" + + activity_dict["activity_type"] = activity_type + + activity_dict["data"] = { + "group": dictization.table_dictize(group, context) + } + activity_create_context = tk.fresh_context(context) + activity_create_context['ignore_auth'] = True + tk.get_action("activity_create")(activity_create_context, activity_dict) + + +# action, context, data_dict, result +def user_changed(sender: str, **kwargs: Any): + for key in ("result", "context", "data_dict"): + if key not in kwargs: + log.warning("Activity subscription ignored") + return + + context: types.Context = kwargs["context"] + result: types.ActionResult.UserUpdate = kwargs["result"] + + if sender == "user_create": + activity_type = "new user" + else: + activity_type = "changed user" + + activity_dict = { + "user_id": result["id"], + "object_id": result["id"], + "activity_type": activity_type, + } + activity_create_context = tk.fresh_context(context) + activity_create_context['ignore_auth'] = True + tk.get_action("activity_create")(activity_create_context, activity_dict) diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/activity_streams/activity_stream_email_notifications.text b/src/ckanext-d4science_theme/ckanext/activity/templates/activity_streams/activity_stream_email_notifications.text new file mode 100644 index 0000000..5cafaae --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/activity_streams/activity_stream_email_notifications.text @@ -0,0 +1,7 @@ +{% set num = activities|length %}{{ ngettext("You have {num} new activity on your {site_title} dashboard", "You have {num} new activities on your {site_title} dashboard", num).format(site_title=g.site_title if g else site_title, num=num) }} {{ _('To view your dashboard, click on this link:') }} + +{% url_for 'activity.dashboard', _external=True %} + +{{ _('You can turn off these email notifications in your {site_title} preferences. To change your preferences, click on this link:').format(site_title=g.site_title if g else site_title) }} + +{% url_for 'user.edit', _external=True %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/ajax_snippets/dashboard.html b/src/ckanext-d4science_theme/ckanext/activity/templates/ajax_snippets/dashboard.html new file mode 100644 index 0000000..c245c23 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/ajax_snippets/dashboard.html @@ -0,0 +1,4 @@ +{# Snippet for unit testing dashboard.js #} +
+ {% snippet 'user/snippets/followee_dropdown.html', context={}, followees=[{"dict": {"id": 1}, "display_name": "Test followee" }, {"dict": {"id": 2}, "display_name": "Not valid" }] %} +
diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/base.html b/src/ckanext-d4science_theme/ckanext/activity/templates/base.html new file mode 100644 index 0000000..be67977 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block styles %} + {{ super() }} + {% asset 'ckanext-activity/activity-css' %} +{% endblock %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/group/activity_stream.html b/src/ckanext-d4science_theme/ckanext/activity/templates/group/activity_stream.html new file mode 100644 index 0000000..0a7484e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/group/activity_stream.html @@ -0,0 +1,15 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block primary_content_inner %} + + {% if activity_types is defined %} + {% snippet 'snippets/activity_type_selector.html', id=id, activity_type=activity_type, activity_types=activity_types, blueprint='activity.group_activity' %} + {% endif %} + + {% snippet 'snippets/stream.html', activity_stream=activity_stream, id=id, object_type='group' %} + + {% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %} + +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/group/changes.html b/src/ckanext-d4science_theme/ckanext/activity/templates/group/changes.html new file mode 100644 index 0000000..00b7307 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/group/changes.html @@ -0,0 +1,64 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ group_dict.name }} {{ g.template_title_delimiter }} Changes {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block breadcrumb_content_selected %}{% endblock %} + +{% block breadcrumb_content %} + {{ super() }} +
  • {% link_for _('Changes'), named_route='activity.group_activity', id=group_dict.name %}
  • +
  • {% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.group_changes', id=activity_diffs[0].activities[1].id %}
  • +{% endblock %} + +{% block primary %} +
    +
    + {% block group_changes_header %} +

    {{ _('Changes') }}

    + {% endblock %} + + {% set select_list1 = h.activity_list_select(group_activity_list, activity_diffs[-1].activities[0].id) %} + {% set select_list2 = h.activity_list_select(group_activity_list, activity_diffs[0].activities[1].id) %} +
    + + + View changes from + to + +
    + +
    + + {# iterate through the list of activity diffs #} +
    + {% for i in range(activity_diffs|length) %} + {% snippet "group/snippets/item_group.html", activity_diff=activity_diffs[i], group_dict=group_dict %} + + {# TODO: display metadata for more than most recent change #} + {% if i == 0 %} + {# button to show JSON metadata diff for the most recent change - not shown by default #} + + + {% endif %} + +
    + {% endfor %} +
    +
    +{% endblock %} + +{% block secondary %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/group/read_base.html b/src/ckanext-d4science_theme/ckanext/activity/templates/group/read_base.html new file mode 100644 index 0000000..03ce862 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/group/read_base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block content_primary_nav %} + {{ super() }} + {{ h.build_nav_icon('activity.group_activity', _('Activity Stream'), id=group_dict.name, offset=0, icon='clock') }} +{% endblock content_primary_nav %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/group/snippets/item_group.html b/src/ckanext-d4science_theme/ckanext/activity/templates/group/snippets/item_group.html new file mode 100644 index 0000000..915fabe --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/group/snippets/item_group.html @@ -0,0 +1,10 @@ +{{ gettext('On %(timestamp)s, %(username)s:', timestamp=h.render_datetime(activity_diff.activities[1].timestamp, with_hours=True, with_seconds=True), username=h.linked_user(activity_diff.activities[1].user_id)) }} + +{% set changes = h.compare_group_dicts(activity_diff.activities[0].data.group, activity_diff.activities[1].data.group, activity_diff.activities[0].id) %} +
      + {% for change in changes %} + {% snippet "snippets/group_changes/{}.html".format( + change.type), change=change %} +
      + {% endfor %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/header.html b/src/ckanext-d4science_theme/ckanext/activity/templates/header.html new file mode 100644 index 0000000..4bd0dff --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/header.html @@ -0,0 +1,15 @@ +{% ckan_extends %} + +{% block header_dashboard %} + {% set new_activities = h.new_activities() %} +
  • + {% set notifications_tooltip = ngettext('Dashboard (%(num)d new item)', 'Dashboard (%(num)d new items)', + new_activities) + %} + + + {{ _('Dashboard') }} + {{ new_activities }} + +
  • +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/organization/activity_stream.html b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/activity_stream.html new file mode 100644 index 0000000..c69b210 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/activity_stream.html @@ -0,0 +1,14 @@ +{% extends "organization/read_base.html" %} + +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block primary_content_inner %} + + {% if activity_types is defined %} + {% snippet 'snippets/activity_type_selector.html', id=id, activity_type=activity_type, activity_types=activity_types, blueprint='activity.organization_activity' %} + {% endif %} + + {% snippet 'snippets/stream.html', activity_stream=activity_stream, id=id, object_type='organization', group_type=group_type %} + + {% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/organization/changes.html b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/changes.html new file mode 100644 index 0000000..7a85b19 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/changes.html @@ -0,0 +1,64 @@ +{% extends "organization/read_base.html" %} + +{% block subtitle %}{{ group_dict.name }} {{ g.template_title_delimiter }} Changes {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block breadcrumb_content_selected %}{% endblock %} + +{% block breadcrumb_content %} + {{ super() }} +
  • {% link_for _('Changes'), named_route='activity.organization_activity', id=group_dict.name %}
  • +
  • {% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.organization_changes', id=activity_diffs[0].activities[1].id %}
  • +{% endblock %} + +{% block primary %} +
    +
    + {% block organization_changes_header %} +

    {{ _('Changes') }}

    + {% endblock %} + + {% set select_list1 = h.activity_list_select(group_activity_list, activity_diffs[-1].activities[0].id) %} + {% set select_list2 = h.activity_list_select(group_activity_list, activity_diffs[0].activities[1].id) %} +
    + + + View changes from + to + +
    + +
    + + {# iterate through the list of activity diffs #} +
    + {% for i in range(activity_diffs|length) %} + {% snippet "organization/snippets/item_organization.html", activity_diff=activity_diffs[i], group_dict=group_dict %} + + {# TODO: display metadata for more than most recent change #} + {% if i == 0 %} + {# button to show JSON metadata diff for the most recent change - not shown by default #} + + + {% endif %} + +
    + {% endfor %} +
    +
    +{% endblock %} + +{% block secondary %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/organization/read_base.html b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/read_base.html new file mode 100644 index 0000000..2aa814f --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/read_base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block content_primary_nav %} + {{ super() }} + {{ h.build_nav_icon('activity.organization_activity', _('Activity Stream'), id=group_dict.name, offset=0, icon='clock') }} +{% endblock content_primary_nav %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/organization/snippets/item_organization.html b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/snippets/item_organization.html new file mode 100644 index 0000000..5e2ee10 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/organization/snippets/item_organization.html @@ -0,0 +1,10 @@ +{{ gettext('On %(timestamp)s, %(username)s:', timestamp=h.render_datetime(activity_diff.activities[1].timestamp, with_hours=True, with_seconds=True), username=h.linked_user(activity_diff.activities[1].user_id)) }} + +{% set changes = h.compare_group_dicts(activity_diff.activities[0].data.group, activity_diff.activities[1].data.group, activity_diff.activities[0].id) %} +
      + {% for change in changes %} + {% snippet "snippets/organization_changes/{}.html".format( + change.type), change=change %} +
      + {% endfor %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/activity_stream.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/activity_stream.html new file mode 100644 index 0000000..40e663e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/activity_stream.html @@ -0,0 +1,24 @@ +{% extends "package/read_base.html" %} + +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block primary_content_inner %} + {% if activity_types is defined %} + {% snippet 'snippets/activity_type_selector.html', id=id, activity_type=activity_type, activity_types=activity_types, blueprint='dataset.activity' %} + {% endif %} + + {% if activity_stream|length > 0 %} + {% snippet 'snippets/stream.html', activity_stream=activity_stream, id=id, object_type='package' %} + {% else %} +

    + {% if activity_type %} + {{ _('No activity found for this type') }} + {% else %} + {{ _('No activity found') }}. + {% endif %} +

    + {% endif %} + + {% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %} + +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/changes.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/changes.html new file mode 100644 index 0000000..51052be --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/changes.html @@ -0,0 +1,64 @@ +{% extends "package/base.html" %} + +{% block subtitle %}{{ pkg_dict.name }} {{ g.template_title_delimiter }} Changes {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block breadcrumb_content_selected %}{% endblock %} + +{% block breadcrumb_content %} + {{ super() }} +
  • {% link_for _('Changes'), named_route='activity.package_activity', id=pkg_dict.name %}
  • +
  • {% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.package_changes', id=activity_diffs[0].activities[1].id %}
  • +{% endblock %} + +{% block primary %} +
    +
    + {% block package_changes_header %} +

    {{ _('Changes') }}

    + {% endblock %} + + {% set select_list1 = h.activity_list_select(pkg_activity_list, activity_diffs[-1].activities[0].id) %} + {% set select_list2 = h.activity_list_select(pkg_activity_list, activity_diffs[0].activities[1].id) %} +
    + + + View changes from + to + +
    + +
    + + {# iterate through the list of activity diffs #} +
    + {% for i in range(activity_diffs|length) %} + {% snippet "package/snippets/change_item.html", activity_diff=activity_diffs[i], pkg_dict=pkg_dict %} + + {# TODO: display metadata for more than most recent change #} + {% if i == 0 %} + {# button to show JSON metadata diff for the most recent change - not shown by default #} + + + {% endif %} + +
    + {% endfor %} +
    +
    +{% endblock %} + +{% block secondary %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/history.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/history.html new file mode 100644 index 0000000..8a29866 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/history.html @@ -0,0 +1,23 @@ +{% extends "package/read.html" %} + +{% block package_description %} + {% block package_archive_notice %} +
    + {% trans url=h.url_for(pkg.type ~ '.read', id=pkg.id) %} + You're currently viewing an old version of this dataset. + Data files may not match the old version of the metadata. + View the current version. + {% endtrans %} +
    + {% endblock %} + + {{ super() }} +{% endblock package_description %} + + +{% block package_resources %} + {% snippet "package/snippets/resources_list.html", pkg=pkg, resources=pkg.resources, is_activity_archive=true %} +{% endblock %} + +{% block content_action %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/read_base.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/read_base.html new file mode 100644 index 0000000..c0ec714 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/read_base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block content_primary_nav %} + {{ super() }} + {{ h.build_nav_icon('activity.package_activity', _('Activity Stream'), id=pkg.id if is_activity_archive else pkg.name, icon='clock') }} +{% endblock content_primary_nav %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/resource_history.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/resource_history.html new file mode 100644 index 0000000..aeee719 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/resource_history.html @@ -0,0 +1,26 @@ +{% extends "package/resource_read.html" %} + +{% block action_manage %} +{% endblock action_manage %} + + +{% block resource_content %} + {% block package_archive_notice %} +
    + {% trans url=h.url_for(pkg.type ~ '.read', id=pkg.id) %} + You're currently viewing an old version of this dataset. + Data files may not match the old version of the metadata. + View the current version. + {% endtrans %} +
    + {% endblock %} + {{ super() }} +{% endblock %} + +{% block data_preview %} +{% endblock %} + + +{% block resources_list %} + {% snippet "package/snippets/resources.html", pkg=pkg, active=res.id, action='read', is_activity_archive=true %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/change_item.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/change_item.html new file mode 100644 index 0000000..df7aacf --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/change_item.html @@ -0,0 +1,10 @@ +{{ gettext('On %(timestamp)s, %(username)s:', timestamp=h.render_datetime(activity_diff.activities[1].timestamp, with_hours=True, with_seconds=True), username=h.linked_user(activity_diff.activities[1].user_id)) }} + +{% set changes = h.compare_pkg_dicts(activity_diff.activities[0].data.package, activity_diff.activities[1].data.package, activity_diff.activities[0].id) %} +
      + {% for change in changes %} + {% snippet "snippets/changes/{}.html".format( + change.type), change=change, pkg_dict=pkg_dict %} +
      + {% endfor %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resource_item.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resource_item.html new file mode 100644 index 0000000..b1fed10 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resource_item.html @@ -0,0 +1,16 @@ +{% ckan_extends %} + +{% block explore_view %} + {% if is_activity_archive %} +
  • + + + {{ _('More information') }} + +
  • + {% else %} + {{ super() }} + {% endif %} + + +{% endblock explore_view %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources.html new file mode 100644 index 0000000..08a9835 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources.html @@ -0,0 +1,12 @@ +{% ckan_extends %} + + +{% block resources_list %} + +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources_list.html b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources_list.html new file mode 100644 index 0000000..40afa88 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/package/snippets/resources_list.html @@ -0,0 +1,15 @@ +{% ckan_extends %} + +{% block resource_list_inner %} + {% for resource in resources %} + {% if is_activity_archive %} + {% set url = h.url_for("activity.resource_history", id=pkg.id, resource_id=resource.id, activity_id=request.view_args.activity_id) %} + {% endif %} + {% snippet 'package/snippets/resource_item.html', pkg=pkg, res=resource, can_edit=false if is_activity_archive else can_edit, is_activity_archive=is_activity_archive, url=url %} + {% endfor %} +{% endblock %} + + +{% block resource_list_empty %} +

    {{ _('This dataset has no data') }}

    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/page.html b/src/ckanext-d4science_theme/ckanext/activity/templates/page.html new file mode 100644 index 0000000..471757b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/page.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block scripts %} + {{ super() }} + {% asset "ckanext-activity/activity" %} +{% endblock scripts %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/added_tag.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/added_tag.html new file mode 100644 index 0000000..ad07377 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/added_tag.html @@ -0,0 +1,15 @@ +
  • + + + + + {{ _('{actor} added the tag {tag} to the dataset {dataset}').format( + actor=ah.actor(activity), + dataset=ah.dataset(activity), + tag=ah.tag(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_group.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_group.html new file mode 100644 index 0000000..13f0cb7 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_group.html @@ -0,0 +1,20 @@ +
  • + + + + + {{ _('{actor} updated the group {group}').format( + actor=ah.actor(activity), + group=ah.group(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + {% if can_show_activity_detail %} +  |  + + {{ _('Changes') }} + + {% endif %} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_organization.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_organization.html new file mode 100644 index 0000000..e63a9bb --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_organization.html @@ -0,0 +1,20 @@ +
  • + + + + + {{ _('{actor} updated the organization {organization}').format( + actor=ah.actor(activity), + organization=ah.organization(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + {% if can_show_activity_detail %} +  |  + + {{ _('Changes') }} + + {% endif %} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_package.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_package.html new file mode 100644 index 0000000..f8eee1e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_package.html @@ -0,0 +1,26 @@ +{% set dataset_type = activity.data.package.type or 'dataset' %} +
  • + + + + + {{ _('{actor} updated the {dataset_type} {dataset}').format( + actor=ah.actor(activity), + dataset_type=h.humanize_entity_type('package', dataset_type, 'activity_record') or _('dataset'), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + {% if can_show_activity_detail %} +  |  + + {{ _('View this version') }} + +  |  + + {{ _('Changes') }} + + {% endif %} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_resource.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_resource.html new file mode 100644 index 0000000..997f76b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_resource.html @@ -0,0 +1,15 @@ +
  • + + + + + {{ _('{actor} updated the resource {resource} in the dataset {dataset}').format( + actor=ah.actor(activity), + resource=ah.resource(activity), + dataset=ah.datset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_user.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_user.html new file mode 100644 index 0000000..6bc2b0d --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/changed_user.html @@ -0,0 +1,13 @@ +
  • + + + + + {{ _('{actor} updated their profile').format( + actor=ah.actor(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_group.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_group.html new file mode 100644 index 0000000..517ce73 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_group.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} deleted the group {group}').format( + actor=ah.actor(activity), + group=ah.group(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_organization.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_organization.html new file mode 100644 index 0000000..8b09cf2 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_organization.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} deleted the organization {organization}').format( + actor=ah.actor(activity), + organization=ah.organization(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_package.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_package.html new file mode 100644 index 0000000..0a733e6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_package.html @@ -0,0 +1,17 @@ +{% set dataset_type = activity.data.package.type or 'dataset' %} + +
  • + + + + + {{ _('{actor} deleted the {dataset_type} {dataset}').format( + actor=ah.actor(activity), + dataset_type=h.humanize_entity_type('package', dataset_type, 'activity_record') or _('dataset'), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_resource.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_resource.html new file mode 100644 index 0000000..7334e58 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/deleted_resource.html @@ -0,0 +1,15 @@ +
  • + + + + + {{ _('{actor} deleted the resource {resource} from the dataset {dataset}').format( + actor=ah.actor(activity), + resource=ah.resource(activity), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/fallback.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/fallback.html new file mode 100644 index 0000000..abaef61 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/fallback.html @@ -0,0 +1,38 @@ +{# + Fallback template for displaying an activity. + It's not pretty, but it is better than TemplateNotFound. + + Params: + activity - the Activity dict + can_show_activity_detail - whether we should render detail about the activity (i.e. "as it was" and diff, alternatively will just display the metadata about the activity) + id - the id or current name of the object (e.g. package name, user id) + ah - dict of template macros to render linked: actor, dataset, organization, user, group +#} +
  • + + + + + {{ _('{actor} {activity_type}').format( + actor=ah.actor(activity), + activity_type=activity.activity_type + )|safe }} + {% if activity.data.package %} + {{ ah.dataset(activity) }} + {% endif %} + {% if activity.data.package %} + {{ ah.dataset(activity) }} + {% endif %} + {% if activity.data.group %} + {# do our best to differentiate between org & group #} + {% if 'group' in activity.activity_type %} + {{ ah.group(activity) }} + {% else %} + {{ ah.organization(activity) }} + {% endif %} + {% endif %} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_dataset.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_dataset.html new file mode 100644 index 0000000..8bd58f0 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_dataset.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} started following {dataset}').format( + actor=ah.actor(activity), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_group.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_group.html new file mode 100644 index 0000000..d26a701 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_group.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} started following {group}').format( + actor=ah.actor(activity), + group=ah.group(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_user.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_user.html new file mode 100644 index 0000000..914be5b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/follow_user.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} started following {user}').format( + actor=ah.actor(activity), + user=ah.user(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_group.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_group.html new file mode 100644 index 0000000..ffa9ab3 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_group.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} created the group {group}').format( + actor=ah.actor(activity), + group=ah.group(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_organization.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_organization.html new file mode 100644 index 0000000..2dc2a93 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_organization.html @@ -0,0 +1,14 @@ +
  • + + + + + {{ _('{actor} created the organization {organization}').format( + actor=ah.actor(activity), + organization=ah.organization(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_package.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_package.html new file mode 100644 index 0000000..b607ce8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_package.html @@ -0,0 +1,22 @@ +{% set dataset_type = activity.data.package.type or 'dataset' %} +
  • + + + + + {{ _('{actor} created the {dataset_type} {dataset}').format( + actor=ah.actor(activity), + dataset_type=h.humanize_entity_type('package', dataset_type,'activity_record') or _('dataset'), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + {% if can_show_activity_detail %} +  |  + + {{ _('View this version') }} + + {% endif %} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_resource.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_resource.html new file mode 100644 index 0000000..057089e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_resource.html @@ -0,0 +1,15 @@ +
  • + + + + + {{ _('{actor} added the resource {resource} to the dataset {dataset}').format( + actor=ah.actor(activity), + resource=ah.resource(activity), + dataset=ah.dataset(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_user.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_user.html new file mode 100644 index 0000000..25bae1a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/new_user.html @@ -0,0 +1,13 @@ +
  • + + + + + {{ _('{actor} signed up').format( + actor=ah.actor(activity) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/removed_tag.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/removed_tag.html new file mode 100644 index 0000000..992fcb8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activities/removed_tag.html @@ -0,0 +1,15 @@ +
  • + + + + + {{ _('{actor} removed the tag {tag} from the dataset {dataset}').format( + actor=ah.actor(activity), + tag=ah.tag(activity), + dataset=ah.dataset(dataset) + )|safe }} +
    + + {{ h.time_ago_from_timestamp(activity.timestamp) }} + +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activity_type_selector.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activity_type_selector.html new file mode 100644 index 0000000..ba2d5e0 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/activity_type_selector.html @@ -0,0 +1,25 @@ +{# + Renders a select element to filter the activity stream + + It uses the activity-stream.js module to dinamically request for a new URl upon selection. + + id - the id or current name of the object (e.g. package name, user id) + activity_type - the current selected activity type + activity_types - the list of activity types the user can filter on + blueprint - blueprint to call when selecting an activity type to filter by (eg: dataset.activity) +#} + +
    + + +
    diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author.html new file mode 100644 index 0000000..41627bd --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Set author of {pkg_link} to {new_author} (previously {old_author})').format( + pkg_link=h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_author = change.new_author, + old_author = change.old_author + ) }} + + {% elif change.method == "add" %} + + {{ _('Set author of {pkg_link} to {new_author}').format( + pkg_link=h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_author = change.new_author + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed author from {pkg_link}').format( + pkg_link=h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id) + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author_email.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author_email.html new file mode 100644 index 0000000..edc4403 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/author_email.html @@ -0,0 +1,47 @@ +
  • +

    + {% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id))%} + {% if change.method == "change" %} + + {{ _('Set author email of ')}} + + {{ change.title }} + + + {{ _('to') }} + + {{change.new_author_email}} + + + {{_('( previously ')}} + + {{change.old_author_email}} + + {{ _(')') }} + + {% elif change.method == "add" %} + + {{_('Set author email of')}} + + {{change.title}} + + + {{ _('to') }} + + {{ change.new_author_email }} + + + {% elif change.method == "remove" %} + + {{ _('Removed author email from')}} + + {{change.title}} + + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/delete_resource.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/delete_resource.html new file mode 100644 index 0000000..6bf96b3 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/delete_resource.html @@ -0,0 +1,10 @@ +
  • +

    + {{ _('Deleted resource {resource_link} from {pkg_link}').format( + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', qualified=True, id=change.pkg_id, + resource_id = change.resource_id, activity_id=change.old_activity_id), + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extension_fields.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extension_fields.html new file mode 100644 index 0000000..af1429e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extension_fields.html @@ -0,0 +1,9 @@ +
  • +

    + {{ _('Changed value of field {key} to {value} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key, + value = change.value + )}} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extra_fields.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extra_fields.html new file mode 100644 index 0000000..e8bdd73 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/extra_fields.html @@ -0,0 +1,85 @@ +
  • +

    + {% if change.method == "add_one_value" %} + + {{ _('Added field {key} with value {value} to {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key, + value = change.value + )}} + + {% elif change.method == "add_one_no_value" %} + + {{ _('Added field {key} to {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key + )}} + + {% elif change.method == "add_multiple" %} + + {{ _('Added the following fields to {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id) + )}} +

      + {% for item in change.key_list %} +
    • + {% if change.value_list[item] != "" %} + {{ _('{key} with value {value}').format( + key = item, + value = change.value_list[item] + )|safe }} + {% else %} + {{ _('{key}').format( + key = item + )|safe }} + {% endif %} +
    • + {% endfor %} +
    + + {% elif change.method == "change_with_old_value" %} + + {{ _('Changed value of field {key} to {new_val} (previously {old_val}) in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key, + new_val = change.new_value, + old_val = change.old_value + )}} + + {% elif change.method == "change_no_old_value" %} + + {{ _('Changed value of field {key} to {new_val} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key, + new_val = change.new_value, + ) }} + + {% elif change.method == "remove_one" %} + + {{ _('Removed field {key} from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + key = change.key, + ) }} + + {% elif change.method == "remove_multiple" %} + + {{ _('Removed the following fields from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id) + ) }} +
      + {% for item in change.key_list %} +
    • + {{ _('{key}').format( + key = item + )| safe }} +
    • + {% endfor %} +
    + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/license.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/license.html new file mode 100644 index 0000000..923d415 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/license.html @@ -0,0 +1,67 @@ +
  • +

    + {% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id)) %} + {# if both of them have URLs #} + {% if change.new_url != "" and change.old_url != "" %} + + {{ _('Changed the license of ') }} + + {{ change.title }} + + + {{ _('to') }} + + {{change.new_title}} + + + {{ _('( previously') }} + + {{change.old_title}} + + {{_(')')}} + + {# if only the new one has a URL #} + {% elif change.new_url != "" and change.old_url == "" %} + + {{ _('Changed the license of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_title}} + + + {{ _('(previously') + change.old_title + ' )'}} + + {# if only the old one has a URL #} + {% elif change.new_url == "" and change.old_url != "" %} + + {{ _('Changed the license of') }} + + {{change.title}} + + + {{ _('to') + change.new_title }} + + {{ _('( previously') }} + + {{change.old_title}} + + {{_(')')}} + + {# otherwise neither has a URL #} + {% else %} + + {{ _('Changed the license of') }} + + {{change.title}} + + + {{ _('to') + change.new_title}} + {{ _('(previously') + change.old_title + _(')')|safe }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer.html new file mode 100644 index 0000000..6a4ae58 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Set maintainer of {pkg_link} to {new_maintainer} (previously {old_maintainer})').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_maintainer = change.new_maintainer, + old_maintainer = change.old_maintainer + ) }} + + {% elif change.method == "add" %} + + {{ _('Set maintainer of {pkg_link} to {new_maintainer}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_maintainer = change.new_maintainer + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed maintainer from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer_email.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer_email.html new file mode 100644 index 0000000..a090cee --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/maintainer_email.html @@ -0,0 +1,47 @@ +
  • +

    + {% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id)) %} + {% if change.method == "change" %} + + {{ _('Set maintainer email of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_maintainer_email}} + + + {{ _('(previously') }} + + {{change.old_maintainer_email}} + + {{ _(')') }} + + {% elif change.method == "add" %} + + {{ _('Set maintainer email of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_maintainer_email}} + + + {% elif change.method == "remove" %} + + {{ _('Removed maintainer email from') }} + + {{change.title}} + + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/name.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/name.html new file mode 100644 index 0000000..3ff43bd --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/name.html @@ -0,0 +1,23 @@ +
  • +

    + {% set old_url = h.url_for('dataset.read', qualified=True, id=change.old_name) %} + {% set new_url = h.url_for('dataset.read', qualified=True, id=change.new_name) %} + + {{ _('Moved')}} + + {{change.title}} + + + {{ _('from') }} + + {{old_url}} + + + {{ _('to') }} + + {{new_url}} + + + +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_file.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_file.html new file mode 100644 index 0000000..285d750 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_file.html @@ -0,0 +1,10 @@ +
  • +

    + {{ _('Uploaded a new file to resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True) + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_resource.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_resource.html new file mode 100644 index 0000000..0d29a68 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/new_resource.html @@ -0,0 +1,17 @@ +{% set dataset_type = request.view_args.package_type or pkg_dict['type'] or 'dataset' %} +{% set pkg_url = h.url_for(dataset_type ~ '.read', id=change.pkg_id) %} +{% set resource_url = h.url_for(dataset_type ~ '_resource.read', id=change.pkg_id, resource_id = change.resource_id, qualified=True) %} + +{% set pkg_link %} +{{ change.title }} +{% endset %} + +{% set resource_link %} +{{ change.resource_name or _('Unnamed resource') }} +{% endset %} + +
  • +

    + {{ _('Added resource {resource_link} to {pkg_link}').format(pkg_link=pkg_link, resource_link=resource_link) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/no_change.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/no_change.html new file mode 100644 index 0000000..0d1ae06 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/no_change.html @@ -0,0 +1,5 @@ +
  • +

    + {{ _('No fields were updated. See the metadata diff for more details.') }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/notes.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/notes.html new file mode 100644 index 0000000..693da43 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/notes.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Updated description of {pkg_link} from

    {old_notes}
    to
    {new_notes}
    ').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + old_notes = change.old_notes, + new_notes = change.new_notes + ) }} + + {% elif change.method == "add" %} + + {{ _('Updated description of {pkg_link} to
    {new_notes}
    ').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_notes = change.new_notes + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed description from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id) + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/org.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/org.html new file mode 100644 index 0000000..f215b30 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/org.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Moved {pkg_link} from organization {old_org_link} to organization {new_org_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + old_org_link = h.nav_link(change.old_org_title, named_route='organization.read', id=change.old_org_id), + new_org_link = h.nav_link(change.new_org_title, named_route='organization.read', id=change.new_org_id) + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed {pkg_link} from organization {old_org_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + old_org_link = h.nav_link(change.old_org_title, named_route='organization.read', id=change.old_org_id) + ) }} + + {% elif change.method == "add" %} + + {{ _('Added {pkg_link} to organization {new_org_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_org_link = h.nav_link(change.new_org_title, named_route='organization.read', id=change.new_org_id) + ) }} + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/private.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/private.html new file mode 100644 index 0000000..377357a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/private.html @@ -0,0 +1,8 @@ +
  • +

    + {{ _('Set visibility of {pkg_link} to {visibility}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + visibility = change.new + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_desc.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_desc.html new file mode 100644 index 0000000..62dc3c5 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_desc.html @@ -0,0 +1,39 @@ +
  • +

    + {% if change.method == "add" %} + + {{ _('Updated description of resource {resource_link} in {pkg_link} to

    {new_desc}
    ').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + new_desc = change.new_desc + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed description from resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True) + )}} + + {% elif change.method == "change" %} + + {{ _('Updated description of resource {resource_link} in {pkg_link} from
    {old_desc}
    to
    {new_desc}
    ').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + new_desc = change.new_desc, + old_desc = change.old_desc + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_extras.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_extras.html new file mode 100644 index 0000000..8423361 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_extras.html @@ -0,0 +1,112 @@ +
  • +

    + {% if change.method == "add_one_value" %} + + {{ _('Added field {key} with value {value} to resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key, + value = change.value + ) }} + + {% elif change.method == "add_one_no_value" %} + + {{ _('Added field {key} to resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key, + ) }} + + {% elif change.method == "add_multiple" %} + + {{ _('Added the following fields to resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True) + ) }} +

      + {% for item in change.key_list %} + {% if change.value_list[item] != "" %} + {{ _('{key} with value {value}').format( + key = item, + value = change.value_list[item] + )|safe }} + {% else %} + {{ _('{key}').format( + key = item + )|safe }} + {% endif %} + {% endfor %} +
    + + {% elif change.method == "remove_one" %} + + {{ _('Removed field {key} from resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key + ) }} + + {% elif change.method == "remove_multiple" %} + + {{ _('Removed the following fields from resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True) + ) }} +
      + {% for item in change.key_list %} + {{ _('{key}').format( + key = item + )|safe }} + {% endfor %} +
    + + {% elif change.method == "change_value_with_old" %} + + {{ _('Changed value of field {key} of resource {resource_link} to {new_val} (previously {old_val}) in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key, + new_val = change.new_value, + old_val = change.old_value + ) }} + + {% elif change.method == "change_value_no_old" %} + + {{ _('Changed value of field {key} to {new_val} in resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key, + new_val = change.new_value + ) }} + + {% elif change.method == "change_value_no_new" %} + + {{ _('Removed the value of field {key} in resource {resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + resource_link = h.nav_link( + change.resource_name, named_route='resource.read', id=change.pkg_id, + resource_id=change.resource_id, qualified=True), + key = change.key, + new_val = change.new_value + ) }} + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_format.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_format.html new file mode 100644 index 0000000..d7ac855 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_format.html @@ -0,0 +1,54 @@ +
  • +

    + {% set format_search_base_url = ( + h.url_for("organization.read", id=change.org_id) + if change.org_id else + h.url_for("dataset.search")) %} + {% set resource_url = (h.url_for( + "resource.read", + id=change.pkg_id, + resource_id=change.resource_id, + qualified=True)) %} + + {% if change.method == "add" %} + + {{ _('Set format of resource') }} + + {{change.resource_name}} + + + {{ _('to') }} + + {{change.format}} + + + {{ _('in') }} + + {{change.title}} + + + {% elif change.method == "change" %} + + {{ _('Set format of resource') }} + + {{change.resource_name}} + + + {{ _('to') }} + + {{change.new_format}} + + + {{ _('(previously') }} + + {{change.old_format}} + + {{ _(')') }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_name.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_name.html new file mode 100644 index 0000000..a98a6be --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/resource_name.html @@ -0,0 +1,13 @@ +
  • +

    + {{ _('Renamed resource {old_resource_link} to {new_resource_link} in {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + old_resource_link = h.nav_link( + change.old_resource_name, named_route='resource.read', id=change.old_pkg_id, + resource_id=change.resource_id, activity_id=change.old_activity_id), + new_resource_link = h.nav_link( + change.new_resource_name, named_route='resource.read', id=change.new_pkg_id, + resource_id=change.resource_id), + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/tags.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/tags.html new file mode 100644 index 0000000..902a02a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/tags.html @@ -0,0 +1,59 @@ +
  • +

    + {% if change.method == "remove_one" %} + + {{ _('Removed tag {tag_link} from {pkg_link}').format( + tag_link = '{tag}'.format( + tag_url = h.url_for('dataset.search') + + "?tags=" + change.tag, + tag = change.tag + )|safe, + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} + + {% elif change.method == "remove_multiple" %} + + {{ _('Removed the following tags from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + )|safe }} + +

      + {% for item in change.tags %} +
    • + {{ _('{tag_link}').format( + tag_link = h.nav_link(item, named_route='dataset.search', tags=item), + )}} +
    • + {% endfor %} +
    + + {% elif change.method == "add_one" %} + + {{ _('Added tag {tag_link} to {pkg_link}').format( + tag_link = h.nav_link(change.tag, named_route='dataset.search', tags=change.tag), + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} + + {% elif change.method == "add_multiple" %} + + {{ _('Added the following tags to {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} +
      + {% for item in change.tags %} +
    • + {# TODO: figure out which controller to actually use here #} + {{ _('{tag_link}').format( + tag_link = h.nav_link(item, named_route='dataset.search', tags=item), + )}} +
    • + {% endfor %} +
    + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/title.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/title.html new file mode 100644 index 0000000..29623bb --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/title.html @@ -0,0 +1,8 @@ +
  • +

    + {{ _('Changed title to {title_link} (previously {old_title})').format( + title_link = h.nav_link(change.new_title, named_route='dataset.read', id=change.id), + old_title = change.old_title + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/url.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/url.html new file mode 100644 index 0000000..c194cd0 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/url.html @@ -0,0 +1,46 @@ +
  • +

    + {% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id)) %} + {% if change.method == "change" %} + + {{ _('Changed the source URL of') }} + + {{change.title}} + + + {{ _('from') }} + + {{change.old_url}} + + + {{ _('to') }} + + {{change.new_url}} + + + {% elif change.method == "remove" %} + + {{ _('Removed the source URL from') }} + + {{change.title}} + + + {% elif change.method == "add" %} + + {{_('Changed the source URL of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_url}} + + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/version.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/version.html new file mode 100644 index 0000000..2c93434 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/changes/version.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Changed the version of {pkg_link} to {new_version} (previously {old_version})').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + old_version = change.old_version, + new_version = change.new_version + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed the version from {pkg_link}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + ) }} + + {% elif change.method == "add" %} + + {{ _('Changed the version of {pkg_link} to {new_version}').format( + pkg_link = h.nav_link(change.title, named_route='dataset.read', id=change.pkg_id), + new_version = change.new_version + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/description.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/description.html new file mode 100644 index 0000000..9210b6a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/description.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Updated description of {group_link} from

    {old_description}
    to
    {new_description}
    ').format( + group_link = h.nav_link(change.title, named_route='group.read', id=change.pkg_id), + old_description = change.old_description, + new_description = change.new_description + ) }} + + {% elif change.method == "add" %} + + {{ _('Updated description of {group_link} to
    {new_description}
    ').format( + group_link = h.nav_link(change.title, named_route='group.read', id=change.pkg_id), + new_description = change.new_description + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed description from {group_link}').format( + group_link = h.nav_link(change.title, named_route='group.read', id=change.pkg_id) + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/image_url.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/image_url.html new file mode 100644 index 0000000..b74aabf --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/image_url.html @@ -0,0 +1,46 @@ +
  • +

    + {% set group_link = (h.url_for('group.read', id=change.pkg_id)) %} + {% if change.method == "change" %} + + {{ _('Changed the image URL of') }} + + {{change.title}} + + + {{ _('from') }} + + {{change.old_image_url}} + + + {{ _('to') }} + + {{change.new_image_url}} + + + {% elif change.method == "remove" %} + + {{ _('Removed the image URL from') }} + + {{change.title}} + + + {% elif change.method == "add" %} + + {{ _('Changed the image URL of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_image_url}} + + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/no_change.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/no_change.html new file mode 100644 index 0000000..0d1ae06 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/no_change.html @@ -0,0 +1,5 @@ +
  • +

    + {{ _('No fields were updated. See the metadata diff for more details.') }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/title.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/title.html new file mode 100644 index 0000000..dc3bbdc --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/group_changes/title.html @@ -0,0 +1,8 @@ +
  • +

    + {{ _('Changed title to {title_link} (previously {old_title})').format( + title_link = h.nav_link(change.new_title, named_route='group.read', id=change.id), + old_title = change.old_title + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/description.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/description.html new file mode 100644 index 0000000..137f186 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/description.html @@ -0,0 +1,30 @@ +
  • +

    + {% if change.method == "change" %} + + {{ _('Updated description of {org_link} from

    {old_description}
    to
    {new_description}
    ').format( + org_link = h.nav_link(change.title, named_route='organization.read', id=change.pkg_id), + old_description = change.old_description, + new_description = change.new_description + ) }} + + {% elif change.method == "add" %} + + {{ _('Updated description of {org_link} to
    {new_description}
    ').format( + org_link = h.nav_link(change.title, named_route='organization.read', id=change.pkg_id), + new_description = change.new_description + ) }} + + {% elif change.method == "remove" %} + + {{ _('Removed description from {org_link}').format( + org_link = h.nav_link(change.title, named_route='organization.read', id=change.pkg_id) + ) }} + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/image_url.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/image_url.html new file mode 100644 index 0000000..5b9ce21 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/image_url.html @@ -0,0 +1,46 @@ +
  • +

    + {% set org_link = (h.url_for('organization.read', id=change.pkg_id)) %} + {% if change.method == "change" %} + + {{ _('Changed the image URL of') }} + + {{change.title}} + + + {{ _('from') }} + + {{change.old_image_url}} + + + {{ _('to') }} + + {{change.new_image_url}} + + + {% elif change.method == "remove" %} + + {{ _('Removed the image URL from') }} + + {{change.title}} + + + {% elif change.method == "add" %} + + {{ _('Changed the image URL of') }} + + {{change.title}} + + + {{ _('to') }} + + {{change.new_image_url}} + + + {% else %} + + {{ _('No fields were updated. See the metadata diff for more details.') }} + + {% endif %} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/no_change.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/no_change.html new file mode 100644 index 0000000..0d1ae06 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/no_change.html @@ -0,0 +1,5 @@ +
  • +

    + {{ _('No fields were updated. See the metadata diff for more details.') }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/title.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/title.html new file mode 100644 index 0000000..444e142 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/organization_changes/title.html @@ -0,0 +1,8 @@ +
  • +

    + {{ _('Changed title to {title_link} (previously {old_title})').format( + title_link = h.nav_link(change.new_title, named_route='organization.read', id=change.id), + old_title = change.old_title + ) }} +

    +
  • diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/pagination.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/pagination.html new file mode 100644 index 0000000..721daf1 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/pagination.html @@ -0,0 +1,7 @@ +{% set class_prev = "btn btn-default" if newer_activities_url else "btn disabled" %} +{% set class_next = "btn btn-default" if older_activities_url else "btn disabled" %} + + \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/stream.html b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/stream.html new file mode 100644 index 0000000..40dd2b4 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/snippets/stream.html @@ -0,0 +1,63 @@ +{% macro actor(activity) %} + + {{ h.linked_user(activity.user_id, 0, 30) }} + +{% endmacro %} + +{% macro dataset(activity) %} + {% set dataset_type = activity.data.package.type or 'dataset' %} + + + {{ activity.data.package.title if activity.data.package else _("unknown") }} + + +{% endmacro %} + +{% macro organization(activity) %} + {% set group_type = group_type or (activity.data.group.type if (activity.data.group and activity.data.group.type) else 'organization') %} + + {{ activity.data.group.title if activity.data.group else _('unknown') }} + +{% endmacro %} + +{% macro user(activity) %} + + {{ h.linked_user(activity.object_id, 0, 20) }} + +{% endmacro %} + +{% macro group(activity) %} + + + {{ activity.data.group.title if activity.data.group else _('unknown') }} + + +{% endmacro %} + +{# Renders the actual stream of activities + +activity_stream - the activity data. e.g. the output from package_activity_list +id - the id or current name of the object (e.g. package name, user id) +object_type - 'package', 'organization', 'group', 'user' + +#} +{% block activity_stream %} +
      + {% set can_show_activity_detail = h.check_access('activity_list', {'id': id, 'include_data': True, 'object_type': object_type}) %} + + {% for activity in activity_stream %} + {%- snippet "snippets/activities/{}.html".format(activity.activity_type.replace(' ', '_')), + "snippets/activities/fallback.html", + activity=activity, can_show_activity_detail=can_show_activity_detail, + ah={ + 'actor': actor, + 'dataset': dataset, + 'organization': organization, + 'user': user, + 'group': group, + }, + id=id + -%} + {% endfor %} +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/user/activity_stream.html b/src/ckanext-d4science_theme/ckanext/activity/templates/user/activity_stream.html new file mode 100644 index 0000000..ca6b5f8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/user/activity_stream.html @@ -0,0 +1,14 @@ +{% extends "user/read.html" %} + +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block primary_content_inner %} +

    + {% block page_heading -%} + {{ _('Activity Stream') }} + {%- endblock %} +

    + {% snippet 'snippets/stream.html', activity_stream=activity_stream, id=id, object_type='user' %} + + {% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/user/dashboard.html b/src/ckanext-d4science_theme/ckanext/activity/templates/user/dashboard.html new file mode 100644 index 0000000..abb2d68 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/user/dashboard.html @@ -0,0 +1,27 @@ +{% ckan_extends %} + +{% block breadcrumb_content %} +
  • {{ _('Dashboard') }}
  • +{% endblock %} + +{% block dashboard_nav_links %} + {{ h.build_nav_icon('activity.dashboard', _('News feed'), icon='list') }} + {{ super() }} +{% endblock %} + + +{% block primary_content_inner %} +
    + {% snippet 'user/snippets/followee_dropdown.html', context=dashboard_activity_stream_context, followees=followee_list %} +

    + {% block page_heading %} + {{ _('News feed') }} + {% endblock %} + {{ _("Activity from items that I'm following") }} +

    + {% snippet 'snippets/stream.html', activity_stream=dashboard_activity_stream %} +
    + + {% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %} + +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/user/edit_user_form.html b/src/ckanext-d4science_theme/ckanext/activity/templates/user/edit_user_form.html new file mode 100644 index 0000000..4747ef8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/user/edit_user_form.html @@ -0,0 +1,12 @@ +{% ckan_extends %} + +{% block extra_fields %} + {% if h.activity_show_email_notifications() %} + {% call form.checkbox('activity_streams_email_notifications', label=_('Subscribe to notification emails'), id='field-activity-streams-email-notifications', value=True, checked=g.userobj.activity_streams_email_notifications) %} + {% set helper_text = _("You will receive notification emails from {site_title}, e.g. when you have new activities on your dashboard."|string) %} + {{ form.info(helper_text.format(site_title=g.site_title)) }} + {% endcall %} + {% endif %} + + {{ super() }} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/user/read_base.html b/src/ckanext-d4science_theme/ckanext/activity/templates/user/read_base.html new file mode 100644 index 0000000..dec036e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/user/read_base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block content_primary_nav %} + {{ super() }} + {{ h.build_nav_icon('activity.user_activity', _('Activity Stream'), id=user.name, icon='clock') }} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/activity/templates/user/snippets/followee_dropdown.html b/src/ckanext-d4science_theme/ckanext/activity/templates/user/snippets/followee_dropdown.html new file mode 100644 index 0000000..f3db328 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/templates/user/snippets/followee_dropdown.html @@ -0,0 +1,52 @@ +{% macro followee_icon(type) -%} + {% if type == 'dataset' %} + + {% elif type == 'user' %} + + {% elif type == 'group' %} + + {% elif type == 'organization' %} + + {% endif %} +{%- endmacro %} + +
    + + +
    +
    +
    + + +
    +
    + {% if followees %} + + {% else %} +

    {{ _('You are not following anything') }}

    + {% endif %} +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/__init__.py b/src/ckanext-d4science_theme/ckanext/activity/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/conftest.py b/src/ckanext-d4science_theme/ckanext/activity/tests/conftest.py new file mode 100644 index 0000000..11408a3 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/tests/conftest.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from pytest_factoryboy import register + +from ckan.tests.factories import CKANFactory +from ckanext.activity.model import Activity + + +@register +class ActivityFactory(CKANFactory): + """A factory class for creating CKAN activity objects.""" + + class Meta: + model = Activity + action = "activity_create" diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/logic/__init__.py b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_action.py b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_action.py new file mode 100644 index 0000000..d32ed6b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_action.py @@ -0,0 +1,2178 @@ +# -*- coding: utf-8 -*- + +import copy +import datetime +import time + +import pytest + +from ckan import model +import ckan.plugins.toolkit as tk + +import ckan.tests.helpers as helpers +import ckan.tests.factories as factories + +from ckanext.activity.model.activity import Activity, package_activity_list + + +def _clear_activities(): + from ckan import model + + model.Session.query(Activity).delete() + model.Session.flush() + + +def _seconds_since_timestamp(timestamp, format_): + dt = datetime.datetime.strptime(timestamp, format_) + now = datetime.datetime.utcnow() + assert now > dt # we assume timestamp is not in the future + return (now - dt).total_seconds() + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("with_plugins") +class TestLimits: + def test_activity_list_actions(self): + actions = [ + "user_activity_list", + "package_activity_list", + "group_activity_list", + "organization_activity_list", + "recently_changed_packages_activity_list", + "current_package_list_with_resources", + ] + for action in actions: + with pytest.raises(tk.ValidationError): + helpers.call_action( + action, + id="test_user", + limit="not_an_int", + offset="not_an_int", + ) + with pytest.raises(tk.ValidationError): + helpers.call_action( + action, id="test_user", limit=-1, offset=-1 + ) + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("non_clean_db", "with_plugins") +class TestActivityShow: + def test_simple_with_data(self, package, user, activity_factory): + activity = activity_factory( + user_id=user["id"], + object_id=package["id"], + activity_type="new package", + data={"package": copy.deepcopy(package), "actor": "Mr Someone"}, + ) + activity_shown = helpers.call_action( + "activity_show", id=activity["id"] + ) + assert activity_shown["user_id"] == user["id"] + assert ( + _seconds_since_timestamp( + activity_shown["timestamp"], "%Y-%m-%dT%H:%M:%S.%f" + ) + < 10 + ) + assert activity_shown["object_id"] == package["id"] + assert activity_shown["data"] == { + "package": package, + "actor": "Mr Someone", + } + assert activity_shown["activity_type"] == "new package" + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestPackageActivityList(object): + def test_create_dataset(self): + user = factories.User() + dataset = factories.Dataset(user=user) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + assert "extras" in activities[0]["data"]["package"] + + def test_change_dataset(self): + user = factories.User() + _clear_activities() + dataset = factories.Dataset(user=user) + original_title = dataset["title"] + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package", + "new package", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + assert ( + activities[0]["data"]["package"]["title"] + == "Dataset with changed title" + ) + + # the old dataset still has the old title + assert activities[1]["activity_type"] == "new package" + assert activities[1]["data"]["package"]["title"] == original_title + + def test_change_dataset_add_extra(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["extras"].append(dict(key="rating", value="great")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + assert "extras" in activities[0]["data"]["package"] + + def test_change_dataset_change_extra(self): + user = factories.User() + dataset = factories.Dataset( + user=user, extras=[dict(key="rating", value="great")] + ) + _clear_activities() + dataset["extras"][0] = dict(key="rating", value="ok") + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + assert "extras" in activities[0]["data"]["package"] + + def test_change_dataset_delete_extra(self): + user = factories.User() + dataset = factories.Dataset( + user=user, extras=[dict(key="rating", value="great")] + ) + _clear_activities() + dataset["extras"] = [] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + assert "extras" in activities[0]["data"]["package"] + + def test_change_dataset_add_resource(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + factories.Resource(package_id=dataset["id"], user=user) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + # NB the detail is not included - that is only added in by + # activity_list_to_html() + + def test_change_dataset_change_resource(self): + user = factories.User() + dataset = factories.Dataset( + user=user, + resources=[dict(url="https://example.com/foo.csv", format="csv")], + ) + _clear_activities() + dataset["resources"][0]["format"] = "pdf" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_delete_resource(self): + user = factories.User() + dataset = factories.Dataset( + user=user, + resources=[dict(url="https://example.com/foo.csv", format="csv")], + ) + _clear_activities() + dataset["resources"] = [] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_add_tag(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["tags"].append(dict(name="checked")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_delete_tag_from_dataset(self): + user = factories.User() + dataset = factories.Dataset(user=user, tags=[dict(name="checked")]) + _clear_activities() + dataset["tags"] = [] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_delete_dataset(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "deleted package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_private_dataset_has_no_activity(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + dataset = factories.Dataset( + private=True, owner_org=org["id"], user=user + ) + dataset["tags"] = [] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_private_dataset_delete_has_no_activity(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + dataset = factories.Dataset( + private=True, owner_org=org["id"], user=user + ) + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def _create_bulk_types_activities(self, types): + dataset = factories.Dataset() + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=dataset["id"], + activity_type=activity_type, + data=None, + ) + for activity_type in types + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return dataset["id"] + + def test_error_bad_search(self): + with pytest.raises(tk.ValidationError): + helpers.call_action( + "package_activity_list", + id=id, + activity_types=["new package"], + exclude_activity_types=["deleted package"], + ) + + def test_activity_types_filter(self): + types = [ + "new package", + "changed package", + "deleted package", + "changed package", + "new package", + ] + id = self._create_bulk_types_activities(types) + + activities_new = helpers.call_action( + "package_activity_list", id=id, activity_types=["new package"] + ) + assert len(activities_new) == 2 + + activities_not_new = helpers.call_action( + "package_activity_list", + id=id, + exclude_activity_types=["new package"], + ) + assert len(activities_not_new) == 3 + + activities_delete = helpers.call_action( + "package_activity_list", id=id, activity_types=["deleted package"] + ) + assert len(activities_delete) == 1 + + activities_not_deleted = helpers.call_action( + "package_activity_list", + id=id, + exclude_activity_types=["deleted package"], + ) + assert len(activities_not_deleted) == 4 + + def _create_bulk_package_activities(self, count): + dataset = factories.Dataset() + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=dataset["id"], + activity_type=None, + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return dataset["id"] + + def test_limit_default(self): + id = self._create_bulk_package_activities(35) + results = helpers.call_action("package_activity_list", id=id) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + id = self._create_bulk_package_activities(7) + results = helpers.call_action("package_activity_list", id=id) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + id = self._create_bulk_package_activities(9) + results = helpers.call_action( + "package_activity_list", id=id, limit="9" + ) + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + def test_normal_user_doesnt_see_hidden_activities(self): + # activity is 'hidden' because dataset is created by site_user + dataset = factories.Dataset() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_doesnt_see_hidden_activities_by_default(self): + # activity is 'hidden' because dataset is created by site_user + dataset = factories.Dataset() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_can_include_hidden_activities(self): + # activity is 'hidden' because dataset is created by site_user + dataset = factories.Dataset() + + activities = helpers.call_action( + "package_activity_list", + include_hidden_activity=True, + id=dataset["id"], + ) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + + def _create_dataset_with_activities(self, updates: int = 3): + user = factories.User() + dataset = factories.Dataset(user=user) + ctx = {"user": user["name"]} + + for c in range(updates): + dataset["title"] = "Dataset v{}".format(c) + helpers.call_action("package_update", context=ctx, **dataset) + + return dataset + + def test_activity_after(self): + """Test activities after timestamp""" + dataset = self._create_dataset_with_activities() + + db_activities = package_activity_list(dataset["id"], limit=10) + pkg_activities = helpers.call_action( + "package_activity_list", + id=dataset["id"], + after=db_activities[2].timestamp.timestamp(), + ) + # we expect just 2 (the first 2) + assert len(pkg_activities) == 2 + # first activity here is the first one. + assert pkg_activities[0]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[0]["timestamp"] + ) + assert pkg_activity_time == db_activities[0].timestamp + + # last activity here is the 2nd one. + assert pkg_activities[1]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[1]["timestamp"] + ) + assert pkg_activity_time == db_activities[1].timestamp + + def test_activity_offset(self): + """Test activities after timestamp""" + dataset = self._create_dataset_with_activities() + + db_activities = package_activity_list(dataset["id"], limit=10) + pkg_activities = helpers.call_action( + "package_activity_list", id=dataset["id"], offset=2 + ) + # we expect just 2 (the last 2) + assert len(pkg_activities) == 2 + # first activity here is the first one. + assert pkg_activities[0]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[0]["timestamp"] + ) + assert pkg_activity_time == db_activities[2].timestamp + + # last activity here is the package creation. + assert pkg_activities[1]["activity_type"] == "new package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[1]["timestamp"] + ) + assert pkg_activity_time == db_activities[3].timestamp + + def test_activity_before(self): + """Test activities before timestamp""" + dataset = self._create_dataset_with_activities() + + db_activities = package_activity_list(dataset["id"], limit=10) + pkg_activities = helpers.call_action( + "package_activity_list", + id=dataset["id"], + before=db_activities[1].timestamp.timestamp(), + ) + # we expect just 2 (the last 2) + assert len(pkg_activities) == 2 + # first activity here is the first one. + assert pkg_activities[0]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[0]["timestamp"] + ) + assert pkg_activity_time == db_activities[2].timestamp + + # last activity here is the package creation. + assert pkg_activities[-1]["activity_type"] == "new package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[-1]["timestamp"] + ) + assert pkg_activity_time == db_activities[3].timestamp + + def test_activity_after_before(self): + """Test activities before timestamp""" + dataset = self._create_dataset_with_activities() + + db_activities = package_activity_list(dataset["id"], limit=10) + pkg_activities = helpers.call_action( + "package_activity_list", + id=dataset["id"], + before=db_activities[1].timestamp.timestamp(), + after=db_activities[3].timestamp.timestamp(), + ) + # we expect just 1 (db_activities[2]) + assert len(pkg_activities) == 1 + # first activity here is the first one. + assert pkg_activities[0]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[0]["timestamp"] + ) + assert pkg_activity_time == db_activities[2].timestamp + + def test_activity_after_before_offset(self): + """Test activities before timestamp""" + dataset = self._create_dataset_with_activities(updates=4) + + db_activities = package_activity_list(dataset["id"], limit=10) + pkg_activities = helpers.call_action( + "package_activity_list", + id=dataset["id"], + before=db_activities[1].timestamp.timestamp(), + after=db_activities[4].timestamp.timestamp(), + offset=1, + ) + # we expect just 1 (db_activities[3]) + assert len(pkg_activities) == 1 + # first activity here is the first one. + assert pkg_activities[0]["activity_type"] == "changed package" + pkg_activity_time = datetime.datetime.fromisoformat( + pkg_activities[0]["timestamp"] + ) + assert pkg_activity_time == db_activities[3].timestamp + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestUserActivityList(object): + def test_create_user(self): + user = factories.User() + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new user" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == user["id"] + + def test_user_update_activity_stream(self): + """Test that the right activity is emitted when updating a user.""" + + user = factories.User() + before = datetime.datetime.utcnow() + + # FIXME we have to pass the email address and password to user_update + # even though we're not updating those fields, otherwise validation + # fails. + helpers.call_action( + "user_update", + id=user["id"], + name=user["name"], + email=user["email"], + password=factories.User.stub().password, + fullname="updated full name", + ) + + activity_stream = helpers.call_action( + "user_activity_list", id=user["id"] + ) + latest_activity = activity_stream[0] + assert latest_activity["activity_type"] == "changed user" + assert latest_activity["object_id"] == user["id"] + assert latest_activity["user_id"] == user["id"] + after = datetime.datetime.utcnow() + timestamp = datetime.datetime.strptime( + latest_activity["timestamp"], "%Y-%m-%dT%H:%M:%S.%f" + ) + assert timestamp >= before and timestamp <= after + + def test_create_dataset(self): + user = factories.User() + _clear_activities() + dataset = factories.Dataset(user=user) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_dataset_changed_by_another_user(self): + user = factories.User() + another_user = factories.Sysadmin() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["extras"].append(dict(key="rating", value="great")) + helpers.call_action( + "package_update", context={"user": another_user["name"]}, **dataset + ) + + # the user might have created the dataset, but a change by another + # user does not show on the user's activity stream + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [] + + def test_change_dataset_add_extra(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["extras"].append(dict(key="rating", value="great")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_add_tag(self): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["tags"].append(dict(name="checked")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_create_group(self): + user = factories.User() + _clear_activities() + group = factories.Group(user=user) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new group" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert activities[0]["data"]["group"]["title"] == group["title"] + + def test_delete_group_using_group_delete(self): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + helpers.call_action( + "group_delete", context={"user": user["name"]}, **group + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted group" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert activities[0]["data"]["group"]["title"] == group["title"] + + def test_delete_group_by_updating_state(self): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + group["state"] = "deleted" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted group" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert activities[0]["data"]["group"]["title"] == group["title"] + + def test_create_organization(self): + user = factories.User() + _clear_activities() + org = factories.Organization(user=user) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new organization" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["group"]["title"] == org["title"] + + def test_delete_org_using_organization_delete(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + helpers.call_action( + "organization_delete", context={"user": user["name"]}, **org + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted organization" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["group"]["title"] == org["title"] + + def test_delete_org_by_updating_state(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + org["state"] = "deleted" + helpers.call_action( + "organization_update", context={"user": user["name"]}, **org + ) + + activities = helpers.call_action("user_activity_list", id=user["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted organization" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["group"]["title"] == org["title"] + + def _create_bulk_user_activities(self, count): + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=None, + activity_type=None, + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return user["id"] + + def test_limit_default(self): + id = self._create_bulk_user_activities(35) + results = helpers.call_action("user_activity_list", id=id) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + id = self._create_bulk_user_activities(7) + results = helpers.call_action("user_activity_list", id=id) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + id = self._create_bulk_user_activities(9) + results = helpers.call_action("user_activity_list", id=id, limit="9") + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestGroupActivityList(object): + def test_create_group(self): + user = factories.User() + group = factories.Group(user=user) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new group" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert activities[0]["data"]["group"]["title"] == group["title"] + + def test_change_group(self): + user = factories.User() + _clear_activities() + group = factories.Group(user=user) + original_title = group["title"] + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed group", + "new group", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert ( + activities[0]["data"]["group"]["title"] + == "Group with changed title" + ) + + # the old group still has the old title + assert activities[1]["activity_type"] == "new group" + assert activities[1]["data"]["group"]["title"] == original_title + + def test_create_dataset(self): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset(self): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + original_title = dataset["title"] + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed package", + "new package", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + # the old dataset still has the old title + assert activities[1]["activity_type"] == "new package" + assert activities[1]["data"]["package"]["title"] == original_title + + def test_change_dataset_add_extra(self): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + _clear_activities() + dataset["extras"].append(dict(key="rating", value="great")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_add_tag(self): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + _clear_activities() + dataset["tags"].append(dict(name="checked")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_delete_dataset(self): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_that_used_to_be_in_the_group(self): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + # remove the dataset from the group + dataset["groups"] = [] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + _clear_activities() + # edit the dataset + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + # dataset change should not show up in its former group + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [] + + def test_delete_dataset_that_used_to_be_in_the_group(self): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + # remove the dataset from the group + dataset["groups"] = [] + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + # NOTE: + # ideally the dataset's deletion would not show up in its old group + # but it can't be helped without _group_activity_query getting very + # complicated + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [ + "deleted package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def _create_bulk_group_activities(self, count): + group = factories.Group() + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=group["id"], + activity_type=None, + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return group["id"] + + def test_limit_default(self): + id = self._create_bulk_group_activities(35) + results = helpers.call_action("group_activity_list", id=id) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + id = self._create_bulk_group_activities(7) + results = helpers.call_action("group_activity_list", id=id) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + id = self._create_bulk_group_activities(9) + results = helpers.call_action("group_activity_list", id=id, limit="9") + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + def test_normal_user_doesnt_see_hidden_activities(self): + # activity is 'hidden' because group is created by site_user + group = factories.Group() + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_doesnt_see_hidden_activities_by_default(self): + # activity is 'hidden' because group is created by site_user + group = factories.Group() + + activities = helpers.call_action("group_activity_list", id=group["id"]) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_can_include_hidden_activities(self): + # activity is 'hidden' because group is created by site_user + group = factories.Group() + + activities = helpers.call_action( + "group_activity_list", include_hidden_activity=True, id=group["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "new group" + ] + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestOrganizationActivityList(object): + def test_bulk_make_public(self): + org = factories.Organization() + + dataset1 = factories.Dataset(owner_org=org["id"], private=True) + dataset2 = factories.Dataset(owner_org=org["id"], private=True) + + helpers.call_action( + "bulk_update_public", + {}, + datasets=[dataset1["id"], dataset2["id"]], + org_id=org["id"], + ) + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert activities[0]["activity_type"] == "changed package" + + def test_bulk_delete(self): + org = factories.Organization() + + dataset1 = factories.Dataset(owner_org=org["id"]) + dataset2 = factories.Dataset(owner_org=org["id"]) + + helpers.call_action( + "bulk_update_delete", + {}, + datasets=[dataset1["id"], dataset2["id"]], + org_id=org["id"], + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert activities[0]["activity_type"] == "deleted package" + + def test_create_organization(self): + user = factories.User() + org = factories.Organization(user=user) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "new organization" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["group"]["title"] == org["title"] + + def test_change_organization(self): + user = factories.User() + _clear_activities() + org = factories.Organization(user=user) + original_title = org["title"] + org["title"] = "Organization with changed title" + helpers.call_action( + "organization_update", context={"user": user["name"]}, **org + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed organization", + "new organization", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert ( + activities[0]["data"]["group"]["title"] + == "Organization with changed title" + ) + + # the old org still has the old title + assert activities[1]["activity_type"] == "new organization" + assert activities[1]["data"]["group"]["title"] == original_title + + def test_create_dataset(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + dataset = factories.Dataset(owner_org=org["id"], user=user) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + dataset = factories.Dataset(owner_org=org["id"], user=user) + original_title = dataset["title"] + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package", + "new package", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + # the old dataset still has the old title + assert activities[1]["activity_type"] == "new package" + assert activities[1]["data"]["package"]["title"] == original_title + + def test_change_dataset_add_tag(self): + user = factories.User() + org = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + dataset["tags"].append(dict(name="checked")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_delete_dataset(self): + user = factories.User() + org = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "deleted package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_that_used_to_be_in_the_org(self): + user = factories.User() + org = factories.Organization(user=user) + org2 = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + # remove the dataset from the org + dataset["owner_org"] = org2["id"] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + _clear_activities() + # edit the dataset + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + # dataset change should not show up in its former group + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_delete_dataset_that_used_to_be_in_the_org(self): + user = factories.User() + org = factories.Organization(user=user) + org2 = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + # remove the dataset from the group + dataset["owner_org"] = org2["id"] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + _clear_activities() + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + # dataset deletion should not show up in its former org + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def _create_bulk_org_activities(self, count): + org = factories.Organization() + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=org["id"], + activity_type=None, + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return org["id"] + + def test_limit_default(self): + id = self._create_bulk_org_activities(35) + results = helpers.call_action("organization_activity_list", id=id) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + id = self._create_bulk_org_activities(7) + results = helpers.call_action("organization_activity_list", id=id) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + id = self._create_bulk_org_activities(9) + results = helpers.call_action( + "organization_activity_list", id=id, limit="9" + ) + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + def test_normal_user_doesnt_see_hidden_activities(self): + # activity is 'hidden' because org is created by site_user + org = factories.Organization() + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_doesnt_see_hidden_activities_by_default(self): + # activity is 'hidden' because org is created by site_user + org = factories.Organization() + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [] + + def test_sysadmin_user_can_include_hidden_activities(self): + # activity is 'hidden' because org is created by site_user + org = factories.Organization() + + activities = helpers.call_action( + "organization_activity_list", + include_hidden_activity=True, + id=org["id"], + ) + assert [activity["activity_type"] for activity in activities] == [ + "new organization" + ] + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestRecentlyChangedPackagesActivityList: + def test_create_dataset(self): + user = factories.User() + org = factories.Dataset(user=user) + + activities = helpers.call_action( + "recently_changed_packages_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["package"]["title"] == org["title"] + + def test_change_dataset(self): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + dataset = factories.Dataset(owner_org=org["id"], user=user) + original_title = dataset["title"] + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "recently_changed_packages_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package", + "new package", + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + # the old dataset still has the old title + assert activities[1]["activity_type"] == "new package" + assert activities[1]["data"]["package"]["title"] == original_title + + def test_change_dataset_add_extra(self): + user = factories.User() + org = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + dataset["extras"].append(dict(key="rating", value="great")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "recently_changed_packages_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_change_dataset_add_tag(self): + user = factories.User() + org = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + dataset["tags"].append(dict(name="checked")) + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "recently_changed_packages_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "changed package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def test_delete_dataset(self): + user = factories.User() + org = factories.Organization(user=user) + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + activities = helpers.call_action( + "organization_activity_list", id=org["id"] + ) + assert [activity["activity_type"] for activity in activities] == [ + "deleted package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + + def _create_bulk_package_activities(self, count): + from ckan import model + + user = factories.User() + + objs = [ + Activity( + user_id=user["id"], + object_id=None, + activity_type="new_package", + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + + def test_limit_default(self): + self._create_bulk_package_activities(35) + results = helpers.call_action( + "recently_changed_packages_activity_list" + ) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + self._create_bulk_package_activities(7) + results = helpers.call_action( + "recently_changed_packages_activity_list" + ) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + self._create_bulk_package_activities(9) + results = helpers.call_action( + "recently_changed_packages_activity_list", limit="9" + ) + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestDashboardActivityList(object): + def test_create_user(self): + user = factories.User() + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [activity["activity_type"] for activity in activities] == [ + "new user" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == user["id"] + # user's own activities are always marked ``'is_new': False`` + assert not activities[0]["is_new"] + + def test_create_dataset(self): + user = factories.User() + _clear_activities() + dataset = factories.Dataset(user=user) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [activity["activity_type"] for activity in activities] == [ + "new package" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == dataset["id"] + assert activities[0]["data"]["package"]["title"] == dataset["title"] + # user's own activities are always marked ``'is_new': False`` + assert not activities[0]["is_new"] + + def test_create_group(self): + user = factories.User() + _clear_activities() + group = factories.Group(user=user) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [activity["activity_type"] for activity in activities] == [ + "new group" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == group["id"] + assert activities[0]["data"]["group"]["title"] == group["title"] + # user's own activities are always marked ``'is_new': False`` + assert not activities[0]["is_new"] + + def test_create_organization(self): + user = factories.User() + _clear_activities() + org = factories.Organization(user=user) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [activity["activity_type"] for activity in activities] == [ + "new organization" + ] + assert activities[0]["user_id"] == user["id"] + assert activities[0]["object_id"] == org["id"] + assert activities[0]["data"]["group"]["title"] == org["title"] + # user's own activities are always marked ``'is_new': False`` + assert not activities[0]["is_new"] + + def _create_bulk_package_activities(self, count): + user = factories.User() + from ckan import model + + objs = [ + Activity( + user_id=user["id"], + object_id=None, + activity_type=None, + data=None, + ) + for _ in range(count) + ] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return user["id"] + + def test_limit_default(self): + id = self._create_bulk_package_activities(35) + results = helpers.call_action( + "dashboard_activity_list", context={"user": id} + ) + assert len(results) == 31 # i.e. default value + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_limit_configured(self): + id = self._create_bulk_package_activities(7) + results = helpers.call_action( + "dashboard_activity_list", context={"user": id} + ) + assert len(results) == 5 # i.e. ckan.activity_list_limit + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + @pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") + def test_limit_hits_max(self): + id = self._create_bulk_package_activities(9) + results = helpers.call_action( + "dashboard_activity_list", limit="9", context={"user": id} + ) + assert len(results) == 7 # i.e. ckan.activity_list_limit_max + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestDashboardNewActivities(object): + def test_users_own_activities(self): + # a user's own activities are not shown as "new" + user = factories.User() + dataset = factories.Dataset(user=user) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + group = factories.Group(user=user) + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + helpers.call_action( + "group_delete", context={"user": user["name"]}, **group + ) + + new_activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [activity["is_new"] for activity in new_activities] == [ + False + ] * 7 + new_activities_count = helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + assert new_activities_count == 0 + + def test_activities_by_a_followed_user(self): + user = factories.User() + followed_user = factories.User() + helpers.call_action( + "follow_user", context={"user": user["name"]}, **followed_user + ) + _clear_activities() + dataset = factories.Dataset(user=followed_user) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", + context={"user": followed_user["name"]}, + **dataset, + ) + helpers.call_action( + "package_delete", + context={"user": followed_user["name"]}, + **dataset, + ) + group = factories.Group(user=followed_user) + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": followed_user["name"]}, **group + ) + helpers.call_action( + "group_delete", context={"user": followed_user["name"]}, **group + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + activity["activity_type"] for activity in activities[::-1] + ] == [ + "new package", + "changed package", + "deleted package", + "new group", + "changed group", + "deleted group", + ] + assert [activity["is_new"] for activity in activities] == [True] * 6 + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 6 + ) + + def test_activities_on_a_followed_dataset(self): + user = factories.User() + another_user = factories.Sysadmin() + _clear_activities() + dataset = factories.Dataset(user=another_user) + helpers.call_action( + "follow_dataset", context={"user": user["name"]}, **dataset + ) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": another_user["name"]}, **dataset + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [ + ("new package", True), + # NB The 'new package' activity is in our activity stream and shows + # as "new" even though it occurred before we followed it. This is + # known & intended design. + ("changed package", True), + ] + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 2 + ) + + def test_activities_on_a_followed_group(self): + user = factories.User() + another_user = factories.Sysadmin() + _clear_activities() + group = factories.Group(user=user) + helpers.call_action( + "follow_group", context={"user": user["name"]}, **group + ) + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": another_user["name"]}, **group + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [ + ("new group", False), # False because user did this one herself + ("changed group", True), + ] + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 1 + ) + + def test_activities_on_a_dataset_in_a_followed_group(self): + user = factories.User() + another_user = factories.Sysadmin() + group = factories.Group(user=user) + helpers.call_action( + "follow_group", context={"user": user["name"]}, **group + ) + _clear_activities() + dataset = factories.Dataset( + groups=[{"name": group["name"]}], user=another_user + ) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": another_user["name"]}, **dataset + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [("new package", True), ("changed package", True)] + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 2 + ) + + def test_activities_on_a_dataset_in_a_followed_org(self): + user = factories.User() + another_user = factories.Sysadmin() + org = factories.Organization(user=user) + helpers.call_action( + "follow_group", context={"user": user["name"]}, **org + ) + _clear_activities() + dataset = factories.Dataset(owner_org=org["id"], user=another_user) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": another_user["name"]}, **dataset + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [("new package", True), ("changed package", True)] + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 2 + ) + + def test_activities_that_should_not_show(self): + user = factories.User() + _clear_activities() + # another_user does some activity unconnected with user + another_user = factories.Sysadmin() + group = factories.Group(user=another_user) + dataset = factories.Dataset( + groups=[{"name": group["name"]}], user=another_user + ) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": another_user["name"]}, **dataset + ) + + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [] + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 0 + ) + + @pytest.mark.ckan_config("ckan.activity_list_limit", "5") + def test_maximum_number_of_new_activities(self): + """Test that the new activities count does not go higher than 5, even + if there are more than 5 new activities from the user's followers.""" + user = factories.User() + another_user = factories.Sysadmin() + dataset = factories.Dataset() + helpers.call_action( + "follow_dataset", context={"user": user["name"]}, **dataset + ) + for n in range(0, 7): + dataset["notes"] = "Updated {n} times".format(n=n) + helpers.call_action( + "package_update", + context={"user": another_user["name"]}, + **dataset, + ) + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 5 + ) + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_request_context", "with_plugins") +class TestSendEmailNotifications(object): + # TODO: this action doesn't do much. Maybe it well be better to move tests + # into lib.email_notifications eventually + + def check_email(self, email, address, name, subject): + assert email[1] == "info@test.ckan.net" + assert email[2] == [address] + assert subject in email[3] + # TODO: Check that body contains link to dashboard and email prefs. + + def test_fresh_setupnotifications(self, mail_server): + helpers.call_action("send_email_notifications") + assert ( + len(mail_server.get_smtp_messages()) == 0 + ), "Notification came out of nowhere" + + def test_single_notification(self, mail_server): + pkg = factories.Dataset() + user = factories.User(activity_streams_email_notifications=True) + helpers.call_action( + "follow_dataset", {"user": user["name"]}, id=pkg["id"] + ) + helpers.call_action("package_update", id=pkg["id"], notes="updated") + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 1 + self.check_email( + messages[0], + user["email"], + user["name"], + "1 new activity from CKAN", + ) + + def test_multiple_notifications(self, mail_server): + pkg = factories.Dataset() + user = factories.User(activity_streams_email_notifications=True) + helpers.call_action( + "follow_dataset", {"user": user["name"]}, id=pkg["id"] + ) + for i in range(3): + helpers.call_action( + "package_update", id=pkg["id"], notes=f"updated {i} times" + ) + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 1 + self.check_email( + messages[0], + user["email"], + user["name"], + "3 new activities from CKAN", + ) + + def test_no_notifications_if_dashboard_visited(self, mail_server): + pkg = factories.Dataset() + user = factories.User(activity_streams_email_notifications=True) + helpers.call_action( + "follow_dataset", {"user": user["name"]}, id=pkg["id"] + ) + helpers.call_action("package_update", id=pkg["id"], notes="updated") + new_activities_count = helpers.call_action( + "dashboard_new_activities_count", + {"user": user["name"]}, + id=pkg["id"], + ) + assert new_activities_count == 1 + + helpers.call_action( + "dashboard_mark_activities_old", + {"user": user["name"]}, + id=pkg["id"], + ) + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 0 + + def test_notifications_disabled_by_default(self): + user = factories.User() + assert not user["activity_streams_email_notifications"] + + def test_no_emails_when_notifications_disabled(self, mail_server): + pkg = factories.Dataset() + user = factories.User() + helpers.call_action( + "follow_dataset", {"user": user["name"]}, id=pkg["id"] + ) + helpers.call_action("package_update", id=pkg["id"], notes="updated") + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 0 + new_activities_count = helpers.call_action( + "dashboard_new_activities_count", + {"user": user["name"]}, + id=pkg["id"], + ) + assert new_activities_count == 1 + + @pytest.mark.ckan_config( + "ckan.activity_streams_email_notifications", False + ) + def test_send_email_notifications_feature_disabled(self, mail_server): + with pytest.raises(tk.ValidationError): + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 0 + + @pytest.mark.ckan_config("ckan.email_notifications_since", ".000001") + def test_email_notifications_since(self, mail_server): + pkg = factories.Dataset() + user = factories.User(activity_streams_email_notifications=True) + helpers.call_action( + "follow_dataset", {"user": user["name"]}, id=pkg["id"] + ) + helpers.call_action("package_update", id=pkg["id"], notes="updated") + time.sleep(0.01) + helpers.call_action("send_email_notifications") + messages = mail_server.get_smtp_messages() + assert len(messages) == 0 + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("non_clean_db", "with_plugins") +class TestDashboardMarkActivitiesOld(object): + def test_mark_as_old_some_activities_by_a_followed_user(self): + # do some activity that will show up on user's dashboard + user = factories.User() + # now some activity that is "new" because it is by a followed user + followed_user = factories.User() + helpers.call_action( + "follow_user", context={"user": user["name"]}, **followed_user + ) + dataset = factories.Dataset(user=followed_user) + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", + context={"user": followed_user["name"]}, + **dataset, + ) + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 3 + ) + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [ + ("new user", False), + ("new user", True), + ("new package", True), + ("changed package", True), + ] + + helpers.call_action( + "dashboard_mark_activities_old", context={"user": user["name"]} + ) + + assert ( + helpers.call_action( + "dashboard_new_activities_count", context={"user": user["id"]} + ) + == 0 + ) + activities = helpers.call_action( + "dashboard_activity_list", context={"user": user["id"]} + ) + assert [ + (activity["activity_type"], activity["is_new"]) + for activity in activities[::-1] + ] == [ + ("new user", False), + ("new user", False), + ("new package", False), + ("changed package", False), + ] + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("non_clean_db", "with_plugins") +class TestFollow: + @pytest.mark.usefixtures("app") + def test_follow_dataset_no_activity(self): + user = factories.User() + dataset = factories.Dataset() + _clear_activities() + helpers.call_action( + "follow_dataset", context={"user": user["name"]}, id=dataset["id"] + ) + assert not helpers.call_action("user_activity_list", id=user["id"]) + + @pytest.mark.usefixtures("app") + def test_follow_group_no_activity(self): + user = factories.User() + group = factories.Group() + _clear_activities() + helpers.call_action( + "follow_group", context={"user": user["name"]}, **group + ) + assert not helpers.call_action("user_activity_list", id=user["id"]) + + @pytest.mark.usefixtures("app") + def test_follow_organization_no_activity(self): + user = factories.User() + org = factories.Organization() + _clear_activities() + helpers.call_action( + "follow_group", context={"user": user["name"]}, **org + ) + assert not helpers.call_action("user_activity_list", id=user["id"]) + + @pytest.mark.usefixtures("app") + def test_follow_user_no_activity(self): + user = factories.User() + user2 = factories.User() + _clear_activities() + helpers.call_action( + "follow_user", context={"user": user["name"]}, **user2 + ) + assert not helpers.call_action("user_activity_list", id=user["id"]) + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestDeferCommitOnCreate(object): + + def test_package_create_defer_commit(self): + dataset_dict = { + "name": "test_dataset", + } + context = { + "defer_commit": True, + "user": factories.User()["name"], + } + + helpers.call_action("package_create", context=context, **dataset_dict) + + model.Session.close() + + with pytest.raises(tk.ObjectNotFound): + helpers.call_action("package_show", id=dataset_dict["name"]) + + assert model.Session.query(Activity).filter( + Activity.activity_type != "new user").count() == 0 + + def test_group_create_defer_commit(self): + group_dict = { + "name": "test_group", + } + context = { + "defer_commit": True, + "user": factories.User()["name"], + } + + helpers.call_action("group_create", context=context, **group_dict) + + model.Session.close() + + with pytest.raises(tk.ObjectNotFound): + helpers.call_action("group_show", id=group_dict["name"]) + + assert model.Session.query(Activity).filter( + Activity.activity_type != "new user").count() == 0 + + def test_organization_create_defer_commit(self): + organization_dict = { + "name": "test_org", + } + context = { + "defer_commit": True, + "user": factories.User()["name"], + } + + helpers.call_action("organization_create", context=context, **organization_dict) + + model.Session.close() + + with pytest.raises(tk.ObjectNotFound): + helpers.call_action("organization_show", id=organization_dict["name"]) + + assert model.Session.query(Activity).filter( + Activity.activity_type != "new user").count() == 0 + + def test_user_create_defer_commit(self): + stub = factories.User.stub() + user_dict = { + "name": stub.name, + "email": stub.email, + "password": "test1234", + } + context = {"defer_commit": True} + + helpers.call_action("user_create", context=context, **user_dict) + + model.Session.close() + + with pytest.raises(tk.ObjectNotFound): + helpers.call_action("user_show", id=user_dict["name"]) + + assert model.Session.query(Activity).count() == 0 diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_auth.py b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_auth.py new file mode 100644 index 0000000..eb11bf6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_auth.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +import pytest + +import ckan.plugins.toolkit as tk +import ckan.tests.helpers as helpers +import ckan.model as model + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("with_plugins") +class TestAuth: + @pytest.mark.ckan_config( + "ckan.auth.public_activity_stream_detail", "false" + ) + def test_config_option_public_activity_stream_detail_denied(self, package): + """Config option says an anon user is not authorized to get activity + stream data/detail. + """ + context = {"user": None, "model": model} + with pytest.raises(tk.NotAuthorized): + helpers.call_auth( + "package_activity_list", + context=context, + id=package["id"], + include_data=True, + ) + + @pytest.mark.ckan_config("ckan.auth.public_activity_stream_detail", "true") + def test_config_option_public_activity_stream_detail(self, package): + """Config option says an anon user is authorized to get activity + stream data/detail. + """ + context = {"user": None, "model": model} + helpers.call_auth( + "package_activity_list", + context=context, + id=package["id"], + include_data=True, + ) + + def test_normal_user_cant_use_it(self, user): + context = {"user": user["name"], "model": model} + + with pytest.raises(tk.NotAuthorized): + helpers.call_auth("activity_create", context=context) diff --git a/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_functional.py b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_functional.py new file mode 100644 index 0000000..23f3828 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/tests/logic/test_functional.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +import pytest + +import ckan.plugins.toolkit as tk +import ckan.tests.helpers as helpers +import ckan.tests.factories as factories + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins", "reset_index") +@pytest.mark.ckan_config("ckan.activity_list_limit", "5") +@pytest.mark.ckan_config("ckan.activity_list_limit_max", "7") +class TestPagination(): + + def test_pagination(self, app): + user = factories.User() + org = factories.Organization() + dataset = factories.Dataset(owner_org=org["id"]) + + for i in range(0, 8): + dataset["notes"] = f"Update number: {i}" + helpers.call_action( + "package_update", + context={"user": user["name"]}, + **dataset, + ) + + # Test initial pagination buttons are rendered correctly + url = tk.url_for("dataset.activity", id=dataset["id"]) + response = app.get(url) + + assert 'Newer activities' in response.body + assert f'Newer activities' in response.body + assert f'February 1, 2018 at 10:58:59 AM UTC' + "" + ) + assert hasattr(html, "__html__") # shows it is safe Markup + + def test_selected(self): + pkg_activity = { + "id": "id1", + "timestamp": datetime.datetime(2018, 2, 1, 10, 58, 59), + } + + out = tk.h.activity_list_select([pkg_activity], "id1") + + html = out[0] + assert ( + str(html) + == '" + ) + assert hasattr(html, "__html__") # shows it is safe Markup + + def test_escaping(self): + pkg_activity = { + "id": '">', # hacked somehow + "timestamp": datetime.datetime(2018, 2, 1, 10, 58, 59), + } + + out = tk.h.activity_list_select([pkg_activity], "") + + html = out[0] + assert str(html).startswith('{}'.format(user["name"], user["fullname"]) + in response + ) + + +def assert_group_link_in_response(group, response): + assert ( + '{2}'.format(group["type"], group["name"], group["title"]) + in response + ) + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestOrganization(object): + def test_simple(self, app): + """Checking the template shows the activity stream.""" + user = factories.User() + org = factories.Organization(user=user) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + assert user["fullname"] in response + assert "created the organization" in response + + def test_create_organization(self, app): + user = factories.User() + org = factories.Organization(user=user) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "created the organization" in response + assert_group_link_in_response(org, response) + + def test_change_organization(self, app): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + org["title"] = "Organization with changed title" + helpers.call_action( + "organization_update", context={"user": user["name"]}, **org + ) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "updated the organization" in response + assert_group_link_in_response(org, response) + + def test_delete_org_using_organization_delete(self, app): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + helpers.call_action( + "organization_delete", context={"user": user["name"]}, **org + ) + + url = url_for("activity.organization_activity", id=org["id"]) + env = {"REMOTE_USER": user["name"]} + app.get(url, extra_environ=env, status=404) + # organization_delete causes the Member to state=deleted and then the + # user doesn't have permission to see their own deleted Organization. + # Therefore you can't render the activity stream of that org. You'd + # hope that organization_delete was the same as organization_update + # state=deleted but they are not... + + def test_delete_org_by_updating_state(self, app): + user = factories.User() + org = factories.Organization(user=user) + _clear_activities() + org["state"] = "deleted" + helpers.call_action( + "organization_update", context={"user": user["name"]}, **org + ) + + url = url_for("activity.organization_activity", id=org["id"]) + env = {"REMOTE_USER": user["name"]} + response = app.get(url, extra_environ=env) + assert_user_link_in_response(user, response) + assert "deleted the organization" in response + assert_group_link_in_response(org, response) + + def test_create_dataset(self, app): + user = factories.User() + org = factories.Organization() + _clear_activities() + dataset = factories.Dataset(owner_org=org["id"], user=user) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "created the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_change_dataset(self, app): + user = factories.User() + org = factories.Organization() + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_delete_dataset(self, app): + user = factories.User() + org = factories.Organization() + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "deleted the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("non_clean_db", "with_plugins") +class TestUser: + def test_simple(self, app): + """Checking the template shows the activity stream.""" + + user = factories.User() + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + assert user["fullname"] in response + assert "signed up" in response + + def test_create_user(self, app): + + user = factories.User() + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "signed up" in response + + def test_change_user(self, app): + + user = factories.User() + _clear_activities() + user["fullname"] = "Mr. Changed Name" + helpers.call_action( + "user_update", context={"user": user["name"]}, **user + ) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "updated their profile" in response + + def test_create_dataset(self, app): + + user = factories.User() + _clear_activities() + dataset = factories.Dataset(user=user) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "created the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_change_dataset(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_delete_dataset(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.user_activity", id=user["id"]) + env = {"REMOTE_USER": user["name"]} + response = app.get(url, extra_environ=env) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "deleted the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_create_group(self, app): + + user = factories.User() + group = factories.Group(user=user) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".group") + + assert_user_link_in_response(user, response) + assert "created the group" in response + assert group["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert group["title"] in href.text.strip() + + def test_change_group(self, app): + + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".group") + assert_user_link_in_response(user, response) + assert "updated the group" in response + assert group["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert group["title"] in href.text.strip() + + def test_delete_group_using_group_delete(self, app): + + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + helpers.call_action( + "group_delete", context={"user": user["name"]}, **group + ) + + url = url_for("activity.user_activity", id=user["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".group") + assert_user_link_in_response(user, response) + assert "deleted the group" in response + assert group["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert group["title"] in href.text.strip() + + def test_delete_group_by_updating_state(self, app): + + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + group["state"] = "deleted" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + url = url_for("activity.group_activity", id=group["id"]) + env = {"REMOTE_USER": user["name"]} + response = app.get(url, extra_environ=env) + assert_user_link_in_response(user, response) + assert "deleted the group" in response + assert_group_link_in_response(group, response) + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("clean_db", "with_plugins") +class TestPackage: + def test_simple(self, app): + """Checking the template shows the activity stream.""" + user = factories.User() + dataset = factories.Dataset(user=user) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + assert user["fullname"] in response + assert "created the dataset" in response + + def test_create_dataset(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + + assert_user_link_in_response(user, response) + assert "created the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_change_dataset(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_create_tag_directly(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["tags"] = [{"name": "some_tag"}] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + + assert len(activities) == 1 + + def test_create_tag(self, app): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["tags"] = [{"name": "some_tag"}] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + + assert len(activities) == 1 + + def test_create_extra(self, app): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + dataset["extras"] = [{"key": "some", "value": "extra"}] + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + + assert len(activities) == 1 + + def test_create_resource(self, app): + user = factories.User() + dataset = factories.Dataset(user=user) + _clear_activities() + helpers.call_action( + "resource_create", + context={"user": user["name"]}, + name="Test resource", + package_id=dataset["id"], + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + + assert len(activities) == 1 + + def test_update_resource(self, app): + user = factories.User() + dataset = factories.Dataset(user=user) + resource = factories.Resource(package_id=dataset["id"]) + _clear_activities() + + helpers.call_action( + "resource_update", + context={"user": user["name"]}, + id=resource["id"], + name="Test resource updated", + package_id=dataset["id"], + ) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + + assert len(activities) == 1 + + def test_delete_dataset(self, app): + user = factories.User() + org = factories.Organization() + dataset = factories.Dataset(owner_org=org["id"], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.organization_activity", id=org["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + + assert_user_link_in_response(user, response) + assert "deleted the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_admin_can_see_old_versions(self, app): + + user = factories.User() + env = {"REMOTE_USER": user["name"]} + dataset = factories.Dataset(user=user) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url, extra_environ=env) + assert "View this version" in response + + def test_public_cant_see_old_versions(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + assert "View this version" not in response + + def test_admin_can_see_changes(self, app): + + user = factories.User() + env = {"REMOTE_USER": user["name"]} + dataset = factories.Dataset() # activities by system user aren't shown + dataset["title"] = "Changed" + helpers.call_action("package_update", **dataset) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url, extra_environ=env) + assert "Changes" in response + + def test_public_cant_see_changes(self, app): + dataset = factories.Dataset() # activities by system user aren't shown + dataset["title"] = "Changed" + helpers.call_action("package_update", **dataset) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + assert "Changes" not in response + + # ckanext-canada uses their IActivity to add their custom activity to the + # list of validators: https://github.com/open-data/ckanext-canada/blob/6870e5bc38a04aa8cef191b5e9eb361f9560872b/ckanext/canada/plugins.py#L596 + # but it's easier here to just hack patch it in + @mock.patch( + "ckanext.activity.logic.validators.object_id_validators", + dict( + list(object_id_validators.items()) + + [("changed datastore", "package_id_exists")] + ), + ) + def test_custom_activity(self, app): + """Render a custom activity""" + + user = factories.User() + organization = factories.Organization( + users=[{"name": user["id"], "capacity": "admin"}] + ) + dataset = factories.Dataset(owner_org=organization["id"], user=user) + resource = factories.Resource(package_id=dataset["id"]) + _clear_activities() + + # Create a custom Activity object. This one is inspired by: + # https://github.com/open-data/ckanext-canada/blob/master/ckanext/canada/activity.py + activity_dict = { + "user_id": user["id"], + "object_id": dataset["id"], + "activity_type": "changed datastore", + "data": { + "resource_id": resource["id"], + "pkg_type": dataset["type"], + "resource_name": "june-2018", + "owner_org": organization["name"], + "count": 5, + }, + } + helpers.call_action("activity_create", **activity_dict) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + # it renders the activity with fallback.html, since we've not defined + # changed_datastore.html in this case + assert "changed datastore" in response + + def test_redirect_also_with_activity_parameter(self, app): + user = factories.User() + dataset = factories.Dataset(user=user) + activity = activity_model.package_activity_list( + dataset["id"], limit=1, offset=0 + )[0] + # view as an admin because viewing the old versions of a dataset + sysadmin = factories.Sysadmin() + env = {"REMOTE_USER": sysadmin["name"]} + response = app.get( + url_for( + "activity.package_history", + id=dataset["id"], + activity_id=activity.id, + ), + status=302, + extra_environ=env, + follow_redirects=False, + ) + expected_path = url_for( + "activity.package_history", + id=dataset["name"], + _external=True, + activity_id=activity.id, + ) + assert response.headers["location"] == expected_path + + def test_read_dataset_as_it_used_to_be(self, app): + dataset = factories.Dataset(title="Original title") + activity = ( + model.Session.query(Activity) + .filter_by(object_id=dataset["id"]) + .one() + ) + dataset["title"] = "Changed title" + helpers.call_action("package_update", **dataset) + + sysadmin = factories.Sysadmin() + env = {"REMOTE_USER": sysadmin["name"]} + response = app.get( + url_for( + "activity.package_history", + id=dataset["name"], + activity_id=activity.id, + ), + extra_environ=env, + ) + assert helpers.body_contains(response, "Original title") + + def test_read_dataset_as_it_used_to_be_but_is_unmigrated(self, app): + # Renders the dataset using the activity detail, when that Activity was + # created with an earlier version of CKAN, and it has not been migrated + # (with migrate_package_activity.py), which should give a 404 + + user = factories.User() + dataset = factories.Dataset(user=user) + + # delete the modern Activity object that's been automatically created + modern_activity = ( + model.Session.query(Activity) + .filter_by(object_id=dataset["id"]) + .one() + ) + modern_activity.delete() + + # Create an Activity object as it was in earlier versions of CKAN. + # This code is based on: + # https://github.com/ckan/ckan/blob/b348bf2fe68db6704ea0a3e22d533ded3d8d4344/ckan/model/package.py#L508 + activity_type = "changed" + dataset_table_dict = dictization.table_dictize( + model.Package.get(dataset["id"]), context={"model": model} + ) + activity = Activity( + user_id=user["id"], + object_id=dataset["id"], + activity_type="%s package" % activity_type, + data={ + # "actor": a legacy activity had no "actor" + # "package": a legacy activity had just the package table, + # rather than the result of package_show + "package": dataset_table_dict + }, + ) + model.Session.add(activity) + + sysadmin = factories.Sysadmin() + env = {"REMOTE_USER": sysadmin["name"]} + app.get( + url_for( + "activity.package_history", + id=dataset["name"], + activity_id=activity.id, + ), + extra_environ=env, + status=404, + ) + + def test_changes(self, app): + user = factories.User() + dataset = factories.Dataset(title="First title", user=user) + dataset["title"] = "Second title" + helpers.call_action("package_update", **dataset) + + activity = activity_model.package_activity_list( + dataset["id"], limit=1, offset=0 + )[0] + env = {"REMOTE_USER": user["name"]} + response = app.get( + url_for("activity.package_changes", id=activity.id), + extra_environ=env, + ) + assert helpers.body_contains(response, "First") + assert helpers.body_contains(response, "Second") + + @pytest.mark.ckan_config("ckan.activity_list_limit", "3") + def test_invalid_get_params(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url, query_string={"before": "XXX"}, status=400) + assert "Invalid parameters" in response.body + + @pytest.mark.ckan_config("ckan.activity_list_limit", "3") + def test_older_activities_url_button(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + dataset["title"] = "Second title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "Third title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "Fourth title" + helpers.call_action("package_update", **dataset) + + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get(url) + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + # Last activity in the first page + before_time = datetime.fromisoformat(activities[2]["timestamp"]) + + # Next page button + older_activities_url_url = "/dataset/activity/{}?before={}".format( + dataset["id"], before_time.timestamp() + ) + assert older_activities_url_url in response.body + + # Prev page button is not in the first page + newer_activities_url_url = "/dataset/activity/{}?after=".format(dataset["id"]) + assert newer_activities_url_url not in response.body + + @pytest.mark.ckan_config("ckan.activity_list_limit", "3") + def test_next_before_buttons(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + dataset["title"] = "Second title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "Third title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "4th title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "5th title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "6th title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "7h title" + helpers.call_action("package_update", **dataset) + + db_activities = activity_model.package_activity_list( + dataset["id"], limit=10 + ) + activities = helpers.call_action( + "package_activity_list", id=dataset["id"] + ) + # Last activity in the first page + last_act_page_1_time = datetime.fromisoformat( + activities[2]["timestamp"] + ) + url = url_for("activity.package_activity", id=dataset["id"]) + response = app.get( + url, query_string={"before": last_act_page_1_time.timestamp()} + ) + + # Next page button exists in page 2 + older_activities_url_url = "/dataset/activity/{}?before={}".format( + dataset["id"], db_activities[5].timestamp.timestamp() + ) + assert older_activities_url_url in response.body + # Prev page button exists in page 2 + newer_activities_url_url = "/dataset/activity/{}?after={}".format( + dataset["id"], db_activities[3].timestamp.timestamp() + ) + assert newer_activities_url_url in response.body + + @pytest.mark.ckan_config("ckan.activity_list_limit", "3") + def test_newer_activities_url_button(self, app): + + user = factories.User() + dataset = factories.Dataset(user=user) + + dataset["title"] = "Second title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "Third title" + helpers.call_action("package_update", **dataset) + dataset["title"] = "Fourth title" + helpers.call_action("package_update", **dataset) + + activities = helpers.call_action( + "package_activity_list", id=dataset["id"], limit=10 + ) + before_time = datetime.fromisoformat(activities[2]["timestamp"]) + + url = url_for("activity.package_activity", id=dataset["id"]) + # url for page 2 + response = app.get( + url, query_string={"before": before_time.timestamp()} + ) + + # There's not a third page + older_activities_url_url = "/dataset/activity/{}?before=".format(dataset["name"]) + assert older_activities_url_url not in response.body + + # previous page exists + after_time = datetime.fromisoformat(activities[3]["timestamp"]) + newer_activities_url_url = "/dataset/activity/{}?after={}".format( + dataset["id"], after_time.timestamp() + ) + assert newer_activities_url_url in response.body + + +@pytest.mark.ckan_config("ckan.plugins", "activity") +@pytest.mark.usefixtures("non_clean_db", "with_plugins") +class TestGroup: + def test_simple(self, app): + """Checking the template shows the activity stream.""" + user = factories.User() + group = factories.Group(user=user) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + assert user["fullname"] in response + assert "created the group" in response + + def test_create_group(self, app): + user = factories.User() + group = factories.Group(user=user) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "created the group" in response + assert_group_link_in_response(group, response) + + def test_change_group(self, app): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + group["title"] = "Group with changed title" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + assert_user_link_in_response(user, response) + assert "updated the group" in response + assert_group_link_in_response(group, response) + + def test_delete_group_using_group_delete(self, app): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + helpers.call_action( + "group_delete", context={"user": user["name"]}, **group + ) + + url = url_for("activity.group_activity", id=group["id"]) + env = {"REMOTE_USER": user["name"]} + app.get(url, extra_environ=env, status=404) + # group_delete causes the Member to state=deleted and then the user + # doesn't have permission to see their own deleted Group. Therefore you + # can't render the activity stream of that group. You'd hope that + # group_delete was the same as group_update state=deleted but they are + # not... + + def test_delete_group_by_updating_state(self, app): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + group["state"] = "deleted" + helpers.call_action( + "group_update", context={"user": user["name"]}, **group + ) + + url = url_for("activity.group_activity", id=group["id"]) + env = {"REMOTE_USER": user["name"]} + response = app.get(url, extra_environ=env) + assert_user_link_in_response(user, response) + assert "deleted the group" in response + assert_group_link_in_response(group, response) + + def test_create_dataset(self, app): + user = factories.User() + group = factories.Group(user=user) + _clear_activities() + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "created the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_change_dataset(self, app): + + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + _clear_activities() + dataset["title"] = "Dataset with changed title" + helpers.call_action( + "package_update", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "updated the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() + + def test_delete_dataset(self, app): + user = factories.User() + group = factories.Group(user=user) + dataset = factories.Dataset(groups=[{"id": group["id"]}], user=user) + _clear_activities() + helpers.call_action( + "package_delete", context={"user": user["name"]}, **dataset + ) + + url = url_for("activity.group_activity", id=group["id"]) + response = app.get(url) + page = BeautifulSoup(response.body) + href = page.select_one(".dataset") + assert_user_link_in_response(user, response) + assert "deleted the dataset" in response + assert dataset["id"] in href.select_one("a")["href"].split("/", 2)[-1] + assert dataset["title"] in href.text.strip() diff --git a/src/ckanext-d4science_theme/ckanext/activity/views.py b/src/ckanext-d4science_theme/ckanext/activity/views.py new file mode 100644 index 0000000..8374d88 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/activity/views.py @@ -0,0 +1,943 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations +import logging + +from datetime import datetime +from typing import Any, Optional, Union, cast + +from flask import Blueprint + +import ckan.plugins.toolkit as tk +import ckan.model as model +from ckan.views.group import ( + set_org, + # TODO: don't use hidden funcitons + _get_group_dict, + _get_group_template, + _replace_group_org, +) + +# TODO: don't use hidden funcitons +from ckan.views.user import _extra_template_variables + +# TODO: don't use hidden funcitons +from ckan.views.dataset import _setup_template_variables + +from ckan.types import Context, Response +from .model import Activity +from .logic.validators import ( + VALIDATORS_PACKAGE_ACTIVITY_TYPES, + VALIDATORS_GROUP_ACTIVITY_TYPES, + VALIDATORS_ORGANIZATION_ACTIVITY_TYPES +) + + +log = logging.getLogger(__name__) +bp = Blueprint("activity", __name__) + + +def _get_activity_stream_limit() -> int: + base_limit = tk.config.get("ckan.activity_list_limit") + max_limit = tk.config.get("ckan.activity_list_limit_max") + return min(base_limit, max_limit) + + +def _get_older_activities_url( + has_more: bool, + stream: list[dict[str, Any]], + **kwargs: Any +) -> Any: + """ Returns pagination's older activities url. + + If "after", we came from older activities, so we know it exists. + if "before" (or is_first_page), we only show older activities if we know + we have more rows + """ + after = tk.request.args.get("after") + before = tk.request.args.get("before") + is_first_page = after is None and before is None + url = None + if after or (has_more and (before or is_first_page)): + before_time = datetime.fromisoformat( + stream[-1]["timestamp"] + ) + url = tk.h.url_for( + tk.request.endpoint, + before=before_time.timestamp(), + **kwargs + ) + + return url + + +def _get_newer_activities_url( + has_more: bool, + stream: list[dict[str, Any]], + **kwargs: Any +) -> Any: + """ Returns pagination's newer activities url. + + if "before", we came from the newer activities, so it exists. + if "after", we only show newer activities if we know + we have more rows + """ + after = tk.request.args.get("after") + before = tk.request.args.get("before") + url = None + + if before or (has_more and after): + after_time = datetime.fromisoformat( + stream[0]["timestamp"] + ) + url = tk.h.url_for( + tk.request.endpoint, + after=after_time.timestamp(), + **kwargs + ) + return url + + +@bp.route("/dataset//resources//history/") +def resource_history(id: str, resource_id: str, activity_id: str) -> str: + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + "for_view": True, + }, + ) + + try: + package = tk.get_action("package_show")(context, {"id": id}) + except (tk.ObjectNotFound, tk.NotAuthorized): + return tk.abort(404, tk._("Dataset not found")) + + # view an 'old' version of the package, as recorded in the + # activity stream + current_pkg = package + try: + activity = context["session"].query(Activity).get(activity_id) + assert activity + package = activity.data["package"] + except AttributeError: + tk.abort(404, tk._("Dataset not found")) + + if package["id"] != current_pkg["id"]: + log.info( + "Mismatch between pkg id in activity and URL {} {}".format( + package["id"], current_pkg["id"] + ) + ) + # the activity is not for the package in the URL - don't allow + # misleading URLs as could be malicious + tk.abort(404, tk._("Activity not found")) + # The name is used lots in the template for links, so fix it to be + # the current one. It's not displayed to the user anyway. + package["name"] = current_pkg["name"] + + # Don't crash on old (unmigrated) activity records, which do not + # include resources or extras. + package.setdefault("resources", []) + + resource = None + for res in package.get("resources", []): + if res["id"] == resource_id: + resource = res + break + if not resource: + return tk.abort(404, tk._("Resource not found")) + + # get package license info + license_id = package.get("license_id") + try: + package["isopen"] = model.Package.get_license_register()[ + license_id + ].isopen() + except KeyError: + package["isopen"] = False + + resource_views = tk.get_action("resource_view_list")( + context, {"id": resource_id} + ) + resource["has_views"] = len(resource_views) > 0 + + current_resource_view = None + view_id = tk.request.args.get("view_id") + if resource["has_views"]: + if view_id: + current_resource_view = [ + rv for rv in resource_views if rv["id"] == view_id + ] + if len(current_resource_view) == 1: + current_resource_view = current_resource_view[0] + else: + return tk.abort(404, tk._("Resource view not found")) + else: + current_resource_view = resource_views[0] + + # required for nav menu + pkg = context["package"] + dataset_type = pkg.type + + # TODO: remove + tk.g.package = package + tk.g.resource = resource + tk.g.pkg = pkg + tk.g.pkg_dict = package + + extra_vars: dict[str, Any] = { + "resource_views": resource_views, + "current_resource_view": current_resource_view, + "dataset_type": dataset_type, + "pkg_dict": package, + "package": package, + "resource": resource, + "pkg": pkg, # NB it is the current version of the dataset, so ignores + # activity_id. Still used though in resource views for + # backward compatibility + } + + return tk.render("package/resource_history.html", extra_vars) + + +@bp.route("/dataset//history/") +def package_history(id: str, activity_id: str) -> Union[Response, str]: + context = cast( + Context, + { + "for_view": True, + "auth_user_obj": tk.g.userobj, + }, + ) + data_dict = {"id": id, "include_tracking": True} + + # check if package exists + try: + pkg_dict = tk.get_action("package_show")(context, data_dict) + pkg = context["package"] + except (tk.ObjectNotFound, tk.NotAuthorized): + return tk.abort(404, tk._("Dataset not found")) + + # if the user specified a package id, redirect to the package name + if ( + data_dict["id"] == pkg_dict["id"] + and data_dict["id"] != pkg_dict["name"] + ): + return tk.h.redirect_to( + "activity.package_history", + id=pkg_dict["name"], + activity_id=activity_id, + ) + + tk.g.pkg_dict = pkg_dict + tk.g.pkg = pkg + # NB templates should not use g.pkg, because it takes no account of + # activity_id + + # view an 'old' version of the package, as recorded in the + # activity stream + try: + activity = tk.get_action("activity_show")( + context, {"id": activity_id, "include_data": True} + ) + except tk.ObjectNotFound: + tk.abort(404, tk._("Activity not found")) + except tk.NotAuthorized: + tk.abort(403, tk._("Unauthorized to view activity data")) + current_pkg = pkg_dict + try: + pkg_dict = activity["data"]["package"] + except KeyError: + tk.abort(404, tk._("Dataset not found")) + if "id" not in pkg_dict or "resources" not in pkg_dict: + log.info( + "Attempt to view unmigrated or badly migrated dataset " + "{} {}".format(id, activity_id) + ) + tk.abort( + 404, tk._("The detail of this dataset activity is not available") + ) + if pkg_dict["id"] != current_pkg["id"]: + log.info( + "Mismatch between pkg id in activity and URL {} {}".format( + pkg_dict["id"], current_pkg["id"] + ) + ) + # the activity is not for the package in the URL - don't allow + # misleading URLs as could be malicious + tk.abort(404, tk._("Activity not found")) + # The name is used lots in the template for links, so fix it to be + # the current one. It's not displayed to the user anyway. + pkg_dict["name"] = current_pkg["name"] + + # Earlier versions of CKAN only stored the package table in the + # activity, so add a placeholder for resources, or the template + # will crash. + pkg_dict.setdefault("resources", []) + + # can the resources be previewed? + for resource in pkg_dict["resources"]: + resource_views = tk.get_action("resource_view_list")( + context, {"id": resource["id"]} + ) + resource["has_views"] = len(resource_views) > 0 + + package_type = pkg_dict["type"] or "dataset" + _setup_template_variables(context, {"id": id}, package_type=package_type) + + return tk.render( + "package/history.html", + { + "dataset_type": package_type, + "pkg_dict": pkg_dict, + "pkg": pkg, + }, + ) + + +@bp.route("/dataset/activity/") +def package_activity(id: str) -> Union[Response, str]: # noqa + """Render this package's public activity stream page.""" + after = tk.request.args.get("after") + before = tk.request.args.get("before") + activity_type = tk.request.args.get("activity_type") + + context = cast( + Context, + { + "for_view": True, + "auth_user_obj": tk.g.userobj, + }, + ) + + data_dict = {"id": id} + limit = _get_activity_stream_limit() + activity_types = [activity_type] if activity_type else None + + try: + pkg_dict = tk.get_action("package_show")(context, data_dict) + activity_dict = { + "id": pkg_dict["id"], + "after": after, + "before": before, + # ask for one more just to know if this query has more results + "limit": limit + 1, + "activity_types": activity_types, + } + activity_stream = tk.get_action("package_activity_list")( + context, activity_dict + ) + dataset_type = pkg_dict["type"] or "dataset" + except tk.ObjectNotFound: + return tk.abort(404, tk._("Dataset not found")) + except tk.NotAuthorized: + return tk.abort(403, tk._("Unauthorized to read dataset %s") % id) + except tk.ValidationError: + return tk.abort(400, tk._("Invalid parameters")) + + has_more = len(activity_stream) > limit + # remove the extra item if exists + if has_more: + if after: + activity_stream.pop(0) + else: + activity_stream.pop() + + older_activities_url = _get_older_activities_url( + has_more, + activity_stream, + id=id, + activity_type=activity_type + ) + + newer_activities_url = _get_newer_activities_url( + has_more, + activity_stream, + id=id, + activity_type=activity_type + ) + + return tk.render( + "package/activity_stream.html", + { + "dataset_type": dataset_type, + "pkg_dict": pkg_dict, + "activity_stream": activity_stream, + "id": id, # i.e. package's current name + "limit": limit, + "has_more": has_more, + "activity_type": activity_type, + "activity_types": VALIDATORS_PACKAGE_ACTIVITY_TYPES.keys(), + "newer_activities_url": newer_activities_url, + "older_activities_url": older_activities_url, + }, + ) + + +@bp.route("/dataset/changes/") +def package_changes(id: str) -> Union[Response, str]: # noqa + """ + Shows the changes to a dataset in one particular activity stream item. + """ + activity_id = id + context = cast(Context, {"auth_user_obj": tk.g.userobj}) + try: + activity_diff = tk.get_action("activity_diff")( + context, + {"id": activity_id, "object_type": "package", "diff_type": "html"}, + ) + except tk.ObjectNotFound as e: + log.info("Activity not found: {} - {}".format(str(e), activity_id)) + return tk.abort(404, tk._("Activity not found")) + except tk.NotAuthorized: + return tk.abort(403, tk._("Unauthorized to view activity data")) + + # 'pkg_dict' needs to go to the templates for page title & breadcrumbs. + # Use the current version of the package, in case the name/title have + # changed, and we need a link to it which works + pkg_id = activity_diff["activities"][1]["data"]["package"]["id"] + current_pkg_dict = tk.get_action("package_show")(context, {"id": pkg_id}) + pkg_activity_list = tk.get_action("package_activity_list")( + context, {"id": pkg_id, "limit": 100} + ) + + return tk.render( + "package/changes.html", + { + "activity_diffs": [activity_diff], + "pkg_dict": current_pkg_dict, + "pkg_activity_list": pkg_activity_list, + "dataset_type": current_pkg_dict["type"], + }, + ) + + +@bp.route("/dataset/changes_multiple") +def package_changes_multiple() -> Union[Response, str]: # noqa + """ + Called when a user specifies a range of versions they want to look at + changes between. Verifies that the range is valid and finds the set of + activity diffs for the changes in the given version range, then + re-renders changes.html with the list. + """ + + new_id = tk.h.get_request_param("new_id") + old_id = tk.h.get_request_param("old_id") + + context = cast(Context, {"auth_user_obj": tk.g.userobj}) + + # check to ensure that the old activity is actually older than + # the new activity + old_activity = tk.get_action("activity_show")( + context, {"id": old_id, "include_data": False} + ) + new_activity = tk.get_action("activity_show")( + context, {"id": new_id, "include_data": False} + ) + + old_timestamp = old_activity["timestamp"] + new_timestamp = new_activity["timestamp"] + + t1 = datetime.strptime(old_timestamp, "%Y-%m-%dT%H:%M:%S.%f") + t2 = datetime.strptime(new_timestamp, "%Y-%m-%dT%H:%M:%S.%f") + + time_diff = t2 - t1 + # if the time difference is negative, just return the change that put us + # at the more recent ID we were just looking at + # TODO: do something better here - go back to the previous page, + # display a warning that the user can't look at a sequence where + # the newest item is older than the oldest one, etc + if time_diff.total_seconds() <= 0: + return package_changes(tk.h.get_request_param("current_new_id")) + + done = False + current_id = new_id + diff_list = [] + + while not done: + try: + activity_diff = tk.get_action("activity_diff")( + context, + { + "id": current_id, + "object_type": "package", + "diff_type": "html", + }, + ) + except tk.ObjectNotFound as e: + log.info("Activity not found: {} - {}".format(str(e), current_id)) + return tk.abort(404, tk._("Activity not found")) + except tk.NotAuthorized: + return tk.abort(403, tk._("Unauthorized to view activity data")) + + diff_list.append(activity_diff) + + if activity_diff["activities"][0]["id"] == old_id: + done = True + else: + current_id = activity_diff["activities"][0]["id"] + + pkg_id: str = diff_list[0]["activities"][1]["data"]["package"]["id"] + current_pkg_dict = tk.get_action("package_show")(context, {"id": pkg_id}) + pkg_activity_list = tk.get_action("package_activity_list")( + context, {"id": pkg_id, "limit": 100} + ) + + return tk.render( + "package/changes.html", + { + "activity_diffs": diff_list, + "pkg_dict": current_pkg_dict, + "pkg_activity_list": pkg_activity_list, + "dataset_type": current_pkg_dict["type"], + }, + ) + + +@bp.route( + "/group/activity/", + endpoint="group_activity", + defaults={"group_type": "group"}, +) +@bp.route( + "/organization/activity/", + endpoint="organization_activity", + defaults={"group_type": "organization"}, +) +def group_activity(id: str, group_type: str) -> str: + """Render this group's public activity stream page.""" + after = tk.request.args.get("after") + before = tk.request.args.get("before") + + if group_type == 'organization': + set_org(True) + + context = cast(Context, {"user": tk.g.user, "for_view": True}) + + try: + group_dict = _get_group_dict(id, group_type) + except (tk.ObjectNotFound, tk.NotAuthorized): + tk.abort(404, tk._("Group not found")) + + real_group_type = group_dict["type"] + action_name = "organization_activity_list" + if not group_dict.get("is_organization"): + action_name = "group_activity_list" + + activity_type = tk.request.args.get("activity_type") + activity_types = [activity_type] if activity_type else None + + limit = _get_activity_stream_limit() + + try: + activity_stream = tk.get_action(action_name)( + context, { + "id": group_dict["id"], + "before": before, + "after": after, + "limit": limit + 1, + "activity_types": activity_types + } + ) + except tk.ValidationError as error: + tk.abort(400, error.message or "") + + filter_types = VALIDATORS_PACKAGE_ACTIVITY_TYPES.copy() + if group_type == 'organization': + filter_types.update(VALIDATORS_ORGANIZATION_ACTIVITY_TYPES) + else: + filter_types.update(VALIDATORS_GROUP_ACTIVITY_TYPES) + + has_more = len(activity_stream) > limit + # remove the extra item if exists + if has_more: + if after: + activity_stream.pop(0) + else: + activity_stream.pop() + + older_activities_url = _get_older_activities_url( + has_more, + activity_stream, + id=id, + activity_type=activity_type + ) + + newer_activities_url = _get_newer_activities_url( + has_more, + activity_stream, + id=id, + activity_type=activity_type + ) + + extra_vars = { + "id": id, + "activity_stream": activity_stream, + "group_type": real_group_type, + "group_dict": group_dict, + "activity_type": activity_type, + "activity_types": filter_types.keys(), + "newer_activities_url": newer_activities_url, + "older_activities_url": older_activities_url + } + + return tk.render( + _get_group_template("activity_template", group_type), extra_vars + ) + + +@bp.route( + "/group/changes/", + defaults={"is_organization": False, "group_type": "group"}, +) +@bp.route( + "/organization/changes/", + endpoint="organization_changes", + defaults={"is_organization": True, "group_type": "organization"}, +) +def group_changes(id: str, group_type: str, is_organization: bool) -> str: + """ + Shows the changes to an organization in one particular activity stream + item. + """ + extra_vars = {} + activity_id = id + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + }, + ) + try: + activity_diff = tk.get_action("activity_diff")( + context, + {"id": activity_id, "object_type": "group", "diff_type": "html"}, + ) + except tk.ObjectNotFound as e: + log.info("Activity not found: {} - {}".format(str(e), activity_id)) + return tk.abort(404, tk._("Activity not found")) + except tk.NotAuthorized: + return tk.abort(403, tk._("Unauthorized to view activity data")) + + # 'group_dict' needs to go to the templates for page title & breadcrumbs. + # Use the current version of the package, in case the name/title have + # changed, and we need a link to it which works + group_id = activity_diff["activities"][1]["data"]["group"]["id"] + current_group_dict = tk.get_action(group_type + "_show")( + context, {"id": group_id} + ) + group_activity_list = tk.get_action(group_type + "_activity_list")( + context, {"id": group_id, "limit": 100} + ) + + extra_vars: dict[str, Any] = { + "activity_diffs": [activity_diff], + "group_dict": current_group_dict, + "group_activity_list": group_activity_list, + "group_type": current_group_dict["type"], + } + + return tk.render(_replace_group_org("group/changes.html"), extra_vars) + + +@bp.route( + "/group/changes_multiple", + defaults={"is_organization": False, "group_type": "group"}, +) +@bp.route( + "/organization/changes_multiple", + endpoint="organization_changes_multiple", + defaults={"is_organization": True, "group_type": "organization"}, +) +def group_changes_multiple(is_organization: bool, group_type: str) -> str: + """ + Called when a user specifies a range of versions they want to look at + changes between. Verifies that the range is valid and finds the set of + activity diffs for the changes in the given version range, then + re-renders changes.html with the list. + """ + extra_vars = {} + new_id = tk.h.get_request_param("new_id") + old_id = tk.h.get_request_param("old_id") + + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + }, + ) + + # check to ensure that the old activity is actually older than + # the new activity + old_activity = tk.get_action("activity_show")( + context, {"id": old_id, "include_data": False} + ) + new_activity = tk.get_action("activity_show")( + context, {"id": new_id, "include_data": False} + ) + + old_timestamp = old_activity["timestamp"] + new_timestamp = new_activity["timestamp"] + + t1 = datetime.strptime(old_timestamp, "%Y-%m-%dT%H:%M:%S.%f") + t2 = datetime.strptime(new_timestamp, "%Y-%m-%dT%H:%M:%S.%f") + + time_diff = t2 - t1 + # if the time difference is negative, just return the change that put us + # at the more recent ID we were just looking at + # TODO: do something better here - go back to the previous page, + # display a warning that the user can't look at a sequence where + # the newest item is older than the oldest one, etc + if time_diff.total_seconds() < 0: + return group_changes( + tk.h.get_request_param("current_new_id"), + group_type, + is_organization, + ) + + done = False + current_id = new_id + diff_list = [] + + while not done: + try: + activity_diff = tk.get_action("activity_diff")( + context, + { + "id": current_id, + "object_type": "group", + "diff_type": "html", + }, + ) + except tk.ObjectNotFound as e: + log.info("Activity not found: {} - {}".format(str(e), current_id)) + return tk.abort(404, tk._("Activity not found")) + except tk.NotAuthorized: + return tk.abort(403, tk._("Unauthorized to view activity data")) + + diff_list.append(activity_diff) + + if activity_diff["activities"][0]["id"] == old_id: + done = True + else: + current_id = activity_diff["activities"][0]["id"] + + group_id: str = diff_list[0]["activities"][1]["data"]["group"]["id"] + current_group_dict = tk.get_action(group_type + "_show")( + context, {"id": group_id} + ) + group_activity_list = tk.get_action(group_type + "_activity_list")( + context, {"id": group_id, "limit": 100} + ) + + extra_vars: dict[str, Any] = { + "activity_diffs": diff_list, + "group_dict": current_group_dict, + "group_activity_list": group_activity_list, + "group_type": current_group_dict["type"], + } + + return tk.render(_replace_group_org("group/changes.html"), extra_vars) + + +@bp.route("/user/activity/") +def user_activity(id: str) -> str: + """Render this user's public activity stream page.""" + after = tk.request.args.get("after") + before = tk.request.args.get("before") + + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + "for_view": True, + }, + ) + data_dict: dict[str, Any] = { + "id": id, + "user_obj": tk.g.userobj, + "include_num_followers": True, + } + try: + tk.check_access("user_show", context, data_dict) + except tk.NotAuthorized: + tk.abort(403, tk._("Not authorized to see this page")) + + extra_vars = _extra_template_variables(context, data_dict) + + limit = _get_activity_stream_limit() + + try: + activity_stream = tk.get_action( + "user_activity_list" + )(context, { + "id": extra_vars["user_dict"]["id"], + "before": before, + "after": after, + "limit": limit + 1, + }) + except tk.ValidationError: + tk.abort(400) + + has_more = len(activity_stream) > limit + # remove the extra item if exists + if has_more: + if after: + activity_stream.pop(0) + else: + activity_stream.pop() + + older_activities_url = _get_older_activities_url( + has_more, + activity_stream, + id=id + ) + + newer_activities_url = _get_newer_activities_url( + has_more, + activity_stream, + id=id + ) + + extra_vars.update({ + "id": id, + "activity_stream": activity_stream, + "newer_activities_url": newer_activities_url, + "older_activities_url": older_activities_url + }) + + return tk.render("user/activity_stream.html", extra_vars) + + +@bp.route("/dashboard/", strict_slashes=False) +def dashboard() -> str: + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + "for_view": True, + }, + ) + data_dict: dict[str, Any] = {"user_obj": tk.g.userobj} + extra_vars = _extra_template_variables(context, data_dict) + + q = tk.request.args.get("q", "") + filter_type = tk.request.args.get("type", "") + filter_id = tk.request.args.get("name", "") + before = tk.request.args.get("before") + after = tk.request.args.get("after") + + limit = _get_activity_stream_limit() + + extra_vars["followee_list"] = tk.get_action("followee_list")( + context, {"id": tk.g.userobj.id, "q": q} + ) + extra_vars["dashboard_activity_stream_context"] = _get_dashboard_context( + filter_type, filter_id, q + ) + activity_stream = tk.h.dashboard_activity_stream( + tk.g.userobj.id, + filter_type=filter_type, + filter_id=filter_id, + limit=limit + 1, + before=before, + after=after + ) + + has_more = len(activity_stream) > limit + # remove the extra item if exists + if has_more: + if after: + activity_stream.pop(0) + else: + activity_stream.pop() + + older_activities_url = _get_older_activities_url( + has_more, + activity_stream, + type=filter_type, + name=filter_id + ) + + newer_activities_url = _get_newer_activities_url( + has_more, + activity_stream, + type=filter_type, + name=filter_id + ) + + extra_vars.update({ + "id": id, + "dashboard_activity_stream": activity_stream, + "newer_activities_url": newer_activities_url, + "older_activities_url": older_activities_url + }) + + # Mark the user's new activities as old whenever they view their + # dashboard page. + tk.get_action("dashboard_mark_activities_old")(context, {}) + + return tk.render("user/dashboard.html", extra_vars) + + +def _get_dashboard_context( + filter_type: Optional[str] = None, + filter_id: Optional[str] = None, + q: Optional[str] = None, +) -> dict[str, Any]: + """Return a dict needed by the dashboard view to determine context.""" + + def display_name(followee: dict[str, Any]) -> Optional[str]: + """Return a display name for a user, group or dataset dict.""" + display_name = followee.get("display_name") + fullname = followee.get("fullname") + title = followee.get("title") + name = followee.get("name") + return display_name or fullname or title or name + + if filter_type and filter_id: + context = cast( + Context, + { + "auth_user_obj": tk.g.userobj, + "for_view": True, + }, + ) + data_dict: dict[str, Any] = { + "id": filter_id, + "include_num_followers": True, + } + followee = None + + action_functions = { + "dataset": "package_show", + "user": "user_show", + "group": "group_show", + "organization": "organization_show", + } + action_name = action_functions.get(filter_type) + if action_name is None: + tk.abort(404, tk._("Follow item not found")) + + action_function = tk.get_action(action_name) + try: + followee = action_function(context, data_dict) + except (tk.ObjectNotFound, tk.NotAuthorized): + tk.abort(404, tk._("{0} not found").format(filter_type)) + + if followee is not None: + return { + "filter_type": filter_type, + "q": q, + "context": display_name(followee), + "selected_id": followee.get("id"), + "dict": followee, + } + + return { + "filter_type": filter_type, + "q": q, + "context": tk._("Everything"), + "selected_id": False, + "dict": None, + } diff --git a/src/ckanext-d4science_theme/ckanext/audioview/__init__.py b/src/ckanext-d4science_theme/ckanext/audioview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/audioview/plugin.py b/src/ckanext-d4science_theme/ckanext/audioview/plugin.py new file mode 100644 index 0000000..e827961 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/audioview/plugin.py @@ -0,0 +1,50 @@ +# encoding: utf-8 +from __future__ import annotations + +from typing import Any + +import ckan.plugins as p +from ckan.types import Context, DataDict +from ckan.common import CKANConfig +from ckan.config.declaration import Declaration, Key + +ignore_empty = p.toolkit.get_validator('ignore_empty') +unicode_safe = p.toolkit.get_validator('unicode_safe') + + +class AudioView(p.SingletonPlugin): + '''This plugin makes views of audio resources, using an link to the audio instead. + {% endtrans %} + diff --git a/src/ckanext-d4science_theme/ckanext/chained_functions/__init__.py b/src/ckanext-d4science_theme/ckanext/chained_functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/chained_functions/plugin.py b/src/ckanext-d4science_theme/ckanext/chained_functions/plugin.py new file mode 100644 index 0000000..5b0bc57 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/chained_functions/plugin.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from ckan.types import Action, AuthFunction, Context, DataDict +from typing import Any, Callable, Optional +import ckan.plugins as p +from ckan.plugins.toolkit import (auth_allow_anonymous_access, + chained_auth_function, + chained_action, + side_effect_free, + chained_helper + ) + + +class ChainedFunctionsPlugin(p.SingletonPlugin): + p.implements(p.IAuthFunctions) + p.implements(p.IActions) + p.implements(p.ITemplateHelpers) + + def get_auth_functions(self): + return { + "user_show": user_show + } + + def get_actions(self): + return { + "package_search": package_search + } + + def get_helpers(self) -> dict[str, Callable[..., Any]]: + return { + "ckan_version": ckan_version + } + + +@auth_allow_anonymous_access +@chained_auth_function +def user_show(next_auth: AuthFunction, context: Context, + data_dict: Optional[DataDict] = None): + return next_auth(context, data_dict) # type: ignore + + +@side_effect_free +@chained_action +def package_search(original_action: Action, context: Context, + data_dict: DataDict): + return original_action(context, data_dict) + + +@chained_helper +def ckan_version(next_func: Callable[..., Any], **kw: Any): + return next_func(**kw) + + +setattr(ckan_version, "some_attribute", "some_value") diff --git a/src/ckanext-d4science_theme/ckanext/chained_functions/tests/__init__.py b/src/ckanext-d4science_theme/ckanext/chained_functions/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ckanext-d4science_theme/ckanext/chained_functions/tests/test_plugin.py b/src/ckanext-d4science_theme/ckanext/chained_functions/tests/test_plugin.py new file mode 100644 index 0000000..5e20737 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/chained_functions/tests/test_plugin.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +import pytest + + +@pytest.mark.ckan_config(u"ckan.plugins", u"chained_functions") +@pytest.mark.usefixtures(u"with_plugins") +class TestChainedFunctionsPlugin(object): + def test_auth_attributes_are_retained(self): + from ckan.authz import _AuthFunctions + user_show = _AuthFunctions.get("user_show") + assert hasattr(user_show, 'auth_allow_anonymous_access') is True + + def test_action_attributes_are_retained(self): + from ckan.plugins.toolkit import get_action + package_search = get_action('package_search') + assert hasattr(package_search, 'side_effect_free') is True + + def test_helper_attributes_are_retained(self): + from ckan.lib.helpers import helper_functions + ckan_version = helper_functions.get('ckan_version') + assert hasattr(ckan_version, "some_attribute") is True diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/__init__.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/__init__.py index 8174e86..6cca9de 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/__init__.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/__init__.py @@ -1,9 +1,19 @@ -from flask import Flask, g -from ckan.lib.app_globals import app_globals +# from flask import Flask, g +# from ckan.lib.app_globals import app_globals +# import ckan.plugins.toolkit as toolkit -app = Flask(__name__) +# app = Flask("d4Science") -# Aggiungi main_css al contesto globale di Flask -@app.before_request -def before_request(): - g.theme = app_globals.main_css \ No newline at end of file +# def base_context(): +# return { +# 'helpers': toolkit.h, +# } + +# @app.context_processor +# def inject_base_context(): +# return base_context() + +# # Aggiungi main_css al contesto globale di Flask +# # @app.before_request +# # def before_request(): +# # g.main_css = app_globals.main_css \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/assets/webassets.yml b/src/ckanext-d4science_theme/ckanext/d4science_theme/assets/webassets.yml index af12079..5b697a1 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/assets/webassets.yml +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/assets/webassets.yml @@ -12,3 +12,4 @@ d4science-css: output: ckanext-d4science_theme/ckanext/d4science_theme/assets/css/d4science_theme.css contents: - css/d4science_theme.css + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/home.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/home.py index 9d08a96..5192b52 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/home.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/home.py @@ -11,7 +11,7 @@ import ckan.lib.maintain as maintain import ckan.lib.base as base import ckan.lib.helpers as h -from flask import Blueprint, render_template +from flask import render_template # Created by Francesco Mangiacrapa diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/organization.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/organization.py index 2ad38e8..90b85cc 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/organization.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/organization.py @@ -8,7 +8,7 @@ import logging import datetime from urllib.parse import urlencode - +import ckan.plugins.toolkit as toolkit import ckan.lib.base as base import ckan.lib.helpers as h import ckan.lib.maintain as maintain @@ -25,7 +25,7 @@ from ckan.common import c, request, _ ''' Created by Francesco Mangiacrapa, see: #8964 ''' -class OrganizationVREController(plugins.toolkit.DefaultGroupForm): #changed for 2.10 : GroupController -> defaultGroupForm +class OrganizationVREController(plugins.toolkit.DefaultOrganizationForm): #changed for 2.10 : GroupController -> defaultOrganizationForm ''' The organization controller is for Organizations, which are implemented as Groups with is_organization=True and group_type='organization'. It works the same as the group controller apart from: @@ -54,19 +54,19 @@ class OrganizationVREController(plugins.toolkit.DefaultGroupForm): #changed for def index(self): group_type = self._guess_group_type() - page = h.get_page_number(request.params) or 1 + page = h.get_page_number(request.args) or 1 items_per_page = 21 context = {'model': model, 'session': model.Session, 'user': c.user, 'for_view': True, 'with_private': False} - q = c.q = request.params.get('q', '') - sort_by = c.sort_by_selected = request.params.get('sort') + q = c.q = request.args.get('q', '') + sort_by = c.sort_by_selected = request.args.get('sort') try: - self._check_access('site_read', context) - self._check_access('group_list', context) - except NotAuthorized: + logic.check_access('site_read', context) + logic.check_access('group_list', context) + except ckan.plugins.toolkit.NotAuthorized: abort(403, _('Not authorized to see this page')) # pass user info to context as needed to view private datasets of @@ -81,7 +81,7 @@ class OrganizationVREController(plugins.toolkit.DefaultGroupForm): #changed for 'sort': sort_by, 'type': group_type or 'group', } - global_results = self._action('group_list')(context, + global_results = toolkit.get_action('group_list')(context, data_dict_global_results) data_dict_page_results = { @@ -92,7 +92,7 @@ class OrganizationVREController(plugins.toolkit.DefaultGroupForm): #changed for 'limit': items_per_page, 'offset': items_per_page * (page - 1), } - page_results = self._action('group_list')(context, + page_results = toolkit.get_action('group_list')(context, data_dict_page_results) c.page = h.Page( @@ -118,15 +118,15 @@ class OrganizationVREController(plugins.toolkit.DefaultGroupForm): #changed for data_dict = {'id': id, 'type': group_type} # unicode format (decoded from utf8) - c.q = request.params.get('q', '') + c.q = request.args.get('q', '') try: # Do not query for the group datasets when dictizing, as they will # be ignored and get requested on the controller anyway data_dict['include_datasets'] = False - c.group_dict = self._action('group_show')(context, data_dict) + c.group_dict = toolkit.get_action('group_show')(context, data_dict) c.group = context['group'] - except (NotFound, NotAuthorized): + except (NotFound, NotAuthorized, ckan.logic.NotFound): abort(404, _('Group not found')) self._read(id, limit, group_type) diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/systemtype.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/systemtype.py index b89b8f3..37e5593 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/systemtype.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/controllers/systemtype.py @@ -1,6 +1,6 @@ import logging import ckan.plugins as p -from ckan.common import OrderedDict, _, g, c +from ckan.common import _, g, c import ckan.lib.search as search import ckan.model as model import ckan.logic as logic @@ -21,7 +21,10 @@ import ckan.model as model import ckan.authz as authz import ckan.lib.plugins import ckan.plugins as plugins -from ckan.common import OrderedDict, c, g, request, _ +from ckan.common import c, g, request, _ + +from collections import OrderedDict +from flask import render_template @@ -29,7 +32,7 @@ from ckan.common import OrderedDict, c, g, request, _ # francesco.mangiacrapa@isti.cnr.it # ISTI-CNR Pisa (ITALY) -class d4STypeController(base.BaseController): +class d4STypeController(): #Overriding controllers.HomeController.index method def index(self): @@ -87,5 +90,6 @@ class d4STypeController(base.BaseController): % g.site_title h.flash_notice(msg, allow_html=True) - return base.render('type/index.html', cache_force=True) + # return base.render('type/index.html', cache_force=True) + return render_template('type/index.html') diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/helpers.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/helpers.py index fd95373..f436c10 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/helpers.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/helpers.py @@ -1,3 +1,4 @@ +from multiprocessing import context import ckan.authz as authz import ckan.model as model from webhelpers2.html import literal @@ -21,6 +22,9 @@ import base64 import sys, os, re import configparser import collections +import ckan.plugins.toolkit as tk +import ckan.logic as logic + log = getLogger(__name__) @@ -74,7 +78,7 @@ def get_header_param(parameter_name, default=None): def get_request_param(parameter_name, default=None): ''' This function allows templates to access query string parameters from the request. ''' - return request.params.get(parameter_name, default) + return request.args.get(parameter_name, default) # ADDED BY FRANCESCO.MANGIACRAPA @@ -337,7 +341,7 @@ def count_facet_items_dict(facet, limit=None, exclude_active=False): for facet_item in c.search_facets.get(facet)['items']: if not len(facet_item['name'].strip()): continue - if not (facet, facet_item['name']) in list(request.params.items()): + if not (facet, facet_item['name']) in list(request.args.items()): facets.append(dict(active=False, **facet_item)) elif not exclude_active: facets.append(dict(active=True, **facet_item)) @@ -639,7 +643,7 @@ def get_ckan_translate_for(ckan_po_file, my_search_string): return my_translate if not ckan_po_file: - ckan_po_file = "/usr/lib/ckan/default/src/ckan/ckan/i18n/en_gcube/LC_MESSAGES/ckan.po" + ckan_po_file = "/usr/lib/ckan/default/src/ckan/ckan/i18n/en_GB/LC_MESSAGES/ckan.po" numlines = 0 numfound = 0 diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/plugin.py b/src/ckanext-d4science_theme/ckanext/d4science_theme/plugin.py index 94e20ab..74c75d9 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/plugin.py +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/plugin.py @@ -10,14 +10,20 @@ import ckan.lib.helpers as h import sqlalchemy as sa from ckanext.d4science_theme.controllers.organization import OrganizationVREController from ckanext.d4science_theme.controllers.home import d4SHomeController +from ckanext.d4science_theme.controllers.systemtype import d4STypeController +from ckanext.d4science_theme.controllers.organization import OrganizationVREController #from ckan.controllers.home import HomeController from ckan.config.middleware.common_middleware import TrackingMiddleware #from ckan.plugins import IRoutes -from flask import Blueprint +from flask import Blueprint, render_template from ckan.common import ( g ) +from flask import Flask, g +from ckan.lib.app_globals import app_globals +import ckan.plugins.toolkit as toolkit + # Created by Francesco Mangiacrapa # francesco.mangiacrapa@isti.cnr.it @@ -44,7 +50,7 @@ def _package_extras_save(extra_dicts, obj, context): session = context["session"] #ADDED BY FRANCESCO MANGIACRAPA - log.debug("extra_dicts: "+str(str(extra_dicts)).encode('utf-8')) + log.debug("extra_dicts: "+ str(extra_dicts)) #print "extra_dicts: "+str(extra_dicts) extras_list = obj.extras_list @@ -63,7 +69,7 @@ def _package_extras_save(extra_dicts, obj, context): #print 'extra_dict key: '+extra_dict["key"] + ', value: '+extra_dict["value"] #new_extras.setdefault(extra_dict["key"], []).append(extra_dict["value"]) if extra_dict.get("deleted"): - log.debug("extra_dict deleted: "+str(extra_dict["key"]).encode('utf-8')) + log.debug("extra_dict deleted: "+str(extra_dict["key"])) #print 'extra_dict deleted: '+extra_dict["key"] continue @@ -72,13 +78,13 @@ def _package_extras_save(extra_dicts, obj, context): new_extras.setdefault(extra_dict["key"], []).append(extra_dict["value"]) #ADDED BY FRANCESCO MANGIACRAPA - log.debug("new_extras: "+str(str(new_extras)).encode('utf-8')) + log.debug("new_extras: "+str(new_extras)) #print "new_extras: "+str(new_extras) #new for key in set(new_extras.keys()) - set(old_extras.keys()): state = 'active' - log.debug("adding key: "+str(key).encode('utf-8')) + log.debug("adding key: "+str(key)) #print "adding key: "+str(key) extra_lst = new_extras[key] for extra in extra_lst: @@ -88,7 +94,7 @@ def _package_extras_save(extra_dicts, obj, context): #deleted for key in set(old_extras.keys()) - set(new_extras.keys()): - log.debug("deleting key: "+str(key).encode('utf-8')) + log.debug("deleting key: "+str(key)) #print "deleting key: "+str(key) extra_lst = extras[key] for extra in extra_lst: @@ -102,13 +108,13 @@ def _package_extras_save(extra_dicts, obj, context): for value in new_extras[key]: old_occur = old_extras[key].count(value) new_occur = new_extras[key].count(value) - log.debug("value: "+str(value).encode('utf-8') + ", new_occur: "+str(new_occur).encode('utf-8')+ ", old_occur: "+str(old_occur).encode('utf-8')) + log.debug("value: "+str(value) + ", new_occur: "+str(new_occur)+ ", old_occur: "+str(old_occur)) #print "value: "+str(value) + ", new_occur: "+str(new_occur) + ", old_occur: "+str(old_occur) # it is an old value deleted or not if value in old_extras[key]: if old_occur == new_occur: #print "extra - occurrences of: "+str(value) +", are equal into both list" - log.debug("extra - occurrences of: "+str(value).encode('utf-8') +", are equal into both list") + log.debug("extra - occurrences of: "+str(value) +", are equal into both list") #there is a little bug, this code return always the first element, so I'm fixing with #FIX-STATUS extra_values = get_package_for_value(extras[key], value) #extras_list.append(extra) @@ -117,44 +123,44 @@ def _package_extras_save(extra_dicts, obj, context): extra.state = state session.add(extra) #print "extra updated: "+str(extra) - log.debug("extra updated: "+str(extra).encode('utf-8')) + log.debug("extra updated: "+str(extra)) elif new_occur > old_occur: #print "extra - a new occurrence of: "+str(value) +", is present into new list, adding it to old list" - log.debug("extra - a new occurrence of: "+str(value).encode('utf-8') +", is present into new list, adding it to old list") + log.debug("extra - a new occurrence of: "+str(value) +", is present into new list, adding it to old list") state = 'active' extra = model.PackageExtra(state=state, key=key, value=value) extra.state = state session.add(extra) extras_list.append(extra) old_extras[key].append(value) - log.debug("old extra values updated: "+str(old_extras[key]).encode('utf-8')) + log.debug("old extra values updated: "+str(old_extras[key])) #print "old extra values updated: "+str(old_extras[key]) else: #remove all occurrences deleted - this code could be optimized, it is run several times but could be performed one shot countDelete = old_occur-new_occur - log.debug("extra - occurrence of: "+str(value).encode('utf-8')+", is not present into new list, removing "+str(countDelete).encode('utf-8')+" occurrence/s from old list") + log.debug("extra - occurrence of: "+str(value) +", is not present into new list, removing "+str(countDelete) + " occurrence/s from old list") #print "extra - occurrence of: "+str(value) +", is not present into new list, removing "+str(countDelete)+" occurrence/s from old list" extra_values = get_package_for_value(extras[key], value) for idx, extra in enumerate(extra_values): if idx < countDelete: #print "extra - occurrence of: "+str(value) +", is not present into new list, removing it from old list" - log.debug("pkg extra deleting: "+str(extra.value).encode('utf-8')) + log.debug("pkg extra deleting: "+str(extra.value)) #print "pkg extra deleting: "+str(extra.value) state = 'deleted' extra.state = state else: #print "pkg extra reactivating: "+str(extra.value) - log.debug("pkg extra reactivating: "+str(extra.value).encode('utf-8')) + log.debug("pkg extra reactivating: "+str(extra.value)) state = 'active' extra.state = state session.add(extra) else: #print "extra new value: "+str(value) - log.debug("extra new value: "+str(value).encode('utf-8')) + log.debug("extra new value: "+str(value)) state = 'active' extra = model.PackageExtra(state=state, key=key, value=value) extra.state = state @@ -169,7 +175,7 @@ def _package_extras_save(extra_dicts, obj, context): extra_values = get_package_for_value(extras[key], value) for extra in extra_values: #print "not present extra deleting: "+str(extra) - log.debug("not present extra deleting: "+str(extra).encode('utf-8')) + log.debug("not present extra deleting: "+str(extra)) state = 'deleted' extra.state = state @@ -284,34 +290,34 @@ class D4Science_ThemePlugin(plugins.SingletonPlugin, toolkit.DefaultDatasetForm) # extension they belong to, to avoid clashing with functions from # other extensions. return { - 'd4science_theme_get_user_role_for_group_or_org': helpers.get_user_role_for_group_or_org, - 'd4science_theme_get_parents_for_group': helpers.get_parents_for_group, - 'get_header_param': helpers.get_header_param, - 'get_request_param': helpers.get_request_param, - 'get_cookie_value': helpers.get_cookie_value, - 'd4science_theme_markdown_extract_html' : helpers.markdown_extract_html, - 'd4science_theme_get_systemtype_value_from_extras' : helpers.get_systemtype_value_from_extras, - 'd4science_theme_get_systemtype_field_dict_from_session' : helpers.get_systemtype_field_dict_from_session, - 'd4science_theme_get_namespace_separator_from_session' : helpers.get_namespace_separator_from_session, - 'd4science_theme_get_extras' : helpers.get_extras, - 'd4science_theme_count_facet_items_dict' : helpers.count_facet_items_dict, - 'd4science_theme_purge_namespace_to_facet': helpers.purge_namespace_to_fieldname, - 'd4science_get_color_for_type': helpers.get_color_for_type, - 'd4science_get_d4s_namespace_controller': helpers.get_d4s_namespace_controller, - 'd4science_get_extras_indexed_for_namespaces': helpers.get_extras_indexed_for_namespaces, - 'd4science_get_namespaces_dict': helpers.get_namespaces_dict, - 'd4science_get_extra_for_category' : helpers.get_extra_for_category, - 'd4science_get_ordered_dictionary': helpers.ordered_dictionary, - 'd4science_get_qrcode_for_url': helpers.qrcode_for_url, - 'd4science_get_list_of_organizations': helpers.get_list_of_organizations, - 'd4science_get_image_display_for_group': helpers.get_image_display_for_group, - 'd4science_get_list_of_groups': helpers.get_list_of_groups, - 'd4science_get_browse_info_for_organisations_or_groups': helpers.get_browse_info_for_organisations_or_groups, - 'd4science_get_user_info': helpers.get_user_info, - 'd4science_get_url_to_icon_for_ckan_entity' : helpers.get_url_to_icon_for_ckan_entity, - 'd4science_get_ckan_translate_for' : helpers.get_ckan_translate_for, - 'd4science_get_location_to_bboxes' : helpers.get_location_to_bboxes, - 'd4science_get_content_moderator_system_placeholder': helpers.get_content_moderator_system_placeholder, + 'd4science_theme_get_user_role_for_group_or_org': helpers.get_user_role_for_group_or_org, + 'd4science_theme_get_parents_for_group': helpers.get_parents_for_group, + 'get_header_param': helpers.get_header_param, + 'get_request_param': helpers.get_request_param, + 'get_cookie_value': helpers.get_cookie_value, + 'd4science_theme_markdown_extract_html' : helpers.markdown_extract_html, + 'd4science_theme_get_systemtype_value_from_extras' : helpers.get_systemtype_value_from_extras, + 'd4science_theme_get_systemtype_field_dict_from_session' : helpers.get_systemtype_field_dict_from_session, + 'd4science_theme_get_namespace_separator_from_session' : helpers.get_namespace_separator_from_session, + 'd4science_theme_get_extras' : helpers.get_extras, + 'd4science_theme_count_facet_items_dict' : helpers.count_facet_items_dict, + 'd4science_theme_purge_namespace_to_facet': helpers.purge_namespace_to_fieldname, + 'd4science_get_color_for_type': helpers.get_color_for_type, + 'd4science_get_d4s_namespace_controller': helpers.get_d4s_namespace_controller, + 'd4science_get_extras_indexed_for_namespaces': helpers.get_extras_indexed_for_namespaces, + 'd4science_get_namespaces_dict': helpers.get_namespaces_dict, + 'd4science_get_extra_for_category' : helpers.get_extra_for_category, + 'd4science_get_ordered_dictionary': helpers.ordered_dictionary, + 'd4science_get_qrcode_for_url': helpers.qrcode_for_url, + 'd4science_get_list_of_organizations': helpers.get_list_of_organizations, + 'd4science_get_image_display_for_group': helpers.get_image_display_for_group, + 'd4science_get_list_of_groups': helpers.get_list_of_groups, + 'd4science_get_browse_info_for_organisations_or_groups': helpers.get_browse_info_for_organisations_or_groups, + 'd4science_get_user_info': helpers.get_user_info, + 'd4science_get_url_to_icon_for_ckan_entity' : helpers.get_url_to_icon_for_ckan_entity, + 'd4science_get_ckan_translate_for' : helpers.get_ckan_translate_for, + 'd4science_get_location_to_bboxes' : helpers.get_location_to_bboxes, + 'd4science_get_content_moderator_system_placeholder': helpers.get_content_moderator_system_placeholder, } #Overriding package_extras_save method @@ -385,12 +391,20 @@ class D4Science_ThemePlugin(plugins.SingletonPlugin, toolkit.DefaultDatasetForm) return facets_dict + def init_template_globals(app): + from ckan.lib.app_globals import app_globals + app.jinja_env.globals.update(g=app_globals) + #changed to migrate to ckan 2.10: def get_blueprint(self): d4sHC = d4SHomeController() + d4sTC = d4STypeController() + d4sOC = OrganizationVREController() blueprint = Blueprint('d4s', self.__module__) rules = [ ('/', 'index', d4sHC.index), + ('/type', 'type', d4sTC.index), + ('/organization_vre', 'organization_vre', d4sOC.index), ] for rule in rules: blueprint.add_url_rule(*rule) diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/activity_streams/activity_stream_email_notifications.text b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/activity_streams/activity_stream_email_notifications.text new file mode 100644 index 0000000..505710b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/activity_streams/activity_stream_email_notifications.text @@ -0,0 +1,7 @@ +{% set num = activities|length %}{{ ngettext("You have {num} new activity on your {site_title} dashboard", "You have {num} new activities on your {site_title} dashboard", num).format(site_title=g.site_title if g else site_title, num=num) }} {{ _('To view your dashboard, click on this link:') }} + +{% url_for 'dashboard.index', _external=True %} + +{{ _('You can turn off these email notifications in your {site_title} preferences. To change your preferences, click on this link:').format(site_title=g.site_title if g else site_title) }} + +{% url_for 'user.edit', _external=True %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/base.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/base.html new file mode 100644 index 0000000..806d8c9 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/base.html @@ -0,0 +1,12 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _('Administration') }}{% endblock %} + +{% block breadcrumb_content %}{% endblock %} + +{% block content_primary_nav %} + {{ h.build_nav_icon('admin.index', _('Sysadmins'), icon='gavel') }} + {{ h.build_nav_icon('admin.config', _('Config'), icon='gear') }} + {{ h.build_nav_icon('admin.trash', _('Trash'), icon='trash') }} + {{ h.build_extra_admin_nav() }} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/config.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/config.html new file mode 100644 index 0000000..0c29625 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/config.html @@ -0,0 +1,72 @@ +{% import 'macros/autoform.html' as autoform %} + +{% extends "admin/base.html" %} + +{% import 'macros/form.html' as form %} + +{% block primary_content_inner %} + + {{ form.errors(error_summary) }} + +
    + {% block admin_form %} + {{ h.csrf_input() }} + + {{ form.input('ckan.site_title', id='field-ckan-site-title', label=_('Site Title'), value=data['ckan.site_title'], error=error, classes=['control-medium']) }} + + {{ form.input('ckan.theme', id='field-ckan-main-css', label=_('Custom Stylesheet'), value=data['ckan.theme'], error=error, classes=['control-medium']) }} + + {{ form.input('ckan.site_description', id='field-ckan-site-description', label=_('Site Tag Line'), value=data['ckan.site_description'], error=error, classes=['control-medium']) }} + + {% set field_url = 'ckan.site_logo' %} + {% set is_upload = data[field_url] and not data[field_url].startswith('http') %} + {% set is_url = data[field_url] and data[field_url].startswith('http') %} + {{ form.image_upload(data, errors, is_upload_enabled=h.uploads_enabled(), is_url=is_url, is_upload=is_upload, upload_label = _('Site logo'), url_label=_('Site logo'), field_url=field_url, field_upload='logo_upload', field_clear='clear_logo_upload' )}} + + {{ form.markdown('ckan.site_about', id='field-ckan-site-about', label=_('About'), value=data['ckan.site_about'], error=error, placeholder=_('About page text')) }} + + {{ form.markdown('ckan.site_intro_text', id='field-ckan-site-intro-text', label=_('Intro Text'), value=data['ckan.site_intro_text'], error=error, placeholder=_('Text on home page')) }} + + {{ form.textarea('ckan.site_custom_css', id='field-ckan-site-custom-css', label=_('Custom CSS'), value=data['ckan.site_custom_css'], error=error, placeholder=_('Customisable css inserted into the page header')) }} + + {{ form.select('ckan.homepage_style', id='field-homepage-style', label=_('Homepage'), options=homepages, selected=data['ckan.homepage_style'], error=error) }} + {% endblock %} +
    + {{ _('Reset') }} + +
    +
    +{% endblock %} + +{% block secondary_content %} +
    +

    + + {{ _('CKAN config options') }} +

    +
    + {% block admin_form_help %} + {% set about_url = h.url_for('home.about') %} + {% set home_url = h.url_for('home.index') %} + {% set docs_url = "http://docs.ckan.org/en/{0}/theming".format(g.ckan_doc_version) %} + {% trans %} +

    Site Title: This is the title of this CKAN instance + It appears in various places throughout CKAN.

    +

    Custom Stylesheet: Define an alternative main CSS file.

    +

    Site Tag Logo: This is the logo that appears in the + header of all the CKAN instance templates.

    +

    About: This text will appear on this CKAN instances + about page.

    +

    Intro Text: This text will appear on this CKAN instances + home page as a welcome to visitors.

    +

    Custom CSS: This is a block of CSS that appears in + <head> tag of every page. If you wish to customize + the templates more fully we recommend + reading the documentation.

    +

    Homepage: This is for choosing a predefined layout for + the modules that appear on your homepage.

    + {% endtrans %} + {% endblock %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/confirm_reset.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/confirm_reset.html new file mode 100644 index 0000000..3e5a716 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/confirm_reset.html @@ -0,0 +1,14 @@ +{% extends "admin/base.html" %} + +{% block subtitle %}{{ _("Confirm Reset") }}{% endblock %} + +{% block primary_content_inner %} +
    + {{ h.csrf_input() }} +

    {{ _('Are you sure you want to reset the config?') }}

    +

    + + +

    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/index.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/index.html new file mode 100644 index 0000000..d6e7439 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/index.html @@ -0,0 +1,84 @@ +{% extends "admin/base.html" %} + +{% block primary_content_inner %} +

    {{ _('Current Sysadmins') }}

    + + + + + + + + + + {% for user in sysadmins %} + + + + + {% endfor %} + +
    {{ _('User') }} 
    {{ h.linked_user(user) }} +
    +
    + {{ h.csrf_input() }} + + + +
    +
    +
    + +
    + +

    {{ _('Promote user to Sysadmin') }}

    + +
    + {{ h.csrf_input() }} +
    +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    +{% endblock %} + +{% block secondary_content %} +
    +

    + + {{ _('Administer CKAN') }} +

    +
    + + {% set docs_url = "http://docs.ckan.org/en/{0}/sysadmin-guide.html".format(g.ckan_doc_version) %} + {% trans %} +

    As a sysadmin user you have full control over this CKAN instance. Proceed with care!

    +

    For guidance on using sysadmin features, see the CKAN sysadmin guide

    + {% endtrans %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/confirm_delete.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/confirm_delete.html new file mode 100644 index 0000000..d4f0a0d --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/confirm_delete.html @@ -0,0 +1,24 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _("Confirm Delete") }}{% endblock %} + +{% block maintag %}
    {% endblock %} + +{% block main_content %} +
    +
    + {% block form %} +

    + {{ _(messages.confirm[ent_type]) }} +

    +

    +

    + {{ h.csrf_input() }} + + +
    +

    + {% endblock %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/data_type.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/data_type.html new file mode 100644 index 0000000..c328439 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/snippets/data_type.html @@ -0,0 +1,57 @@ +
    +
    +

    + +

    + +{# entities list can be of different types #} +{% set items = [] %} + + +
    +
      + {% for entity in entities %} + {% set title = entity.title or entity.name %} + {% do items.append(title) %} +
    • + + {{ title|truncate(80) }} + +
    • + {% else %} +

      + {{ _(messages.empty[ent_type]) }} +

      + {% endfor %} +
    + + + {% if items|length > 0 %} +
    + {{ h.csrf_input() }} + + + {{ _('Purge') }} + +
    + {% endif %} +
    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/trash.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/trash.html new file mode 100644 index 0000000..f3331a6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/admin/trash.html @@ -0,0 +1,38 @@ +{% extends "admin/base.html" %} + +{% block primary_content_inner %} +
    + {{ h.csrf_input() }} + +
    + +{% for ent_type, entities in data.items() %} + {% snippet "admin/snippets/data_type.html", ent_type=ent_type, entities=entities, messages=messages %} +{% endfor %} +{% endblock %} + +{% block secondary_content %} +
    +

    + + {{ _("Trash") }} +

    +
    +

    + {% trans %} + Purge deleted datasets, organizations or groups forever and irreversibly. + {% endtrans %} +

    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/custom_fields.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/custom_fields.html new file mode 100644 index 0000000..90f7344 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/custom_fields.html @@ -0,0 +1,4 @@ +{# Snippet for unit testing custom-fields.js #} +
    + {% snippet 'snippets/custom_form_fields.html', extras=[{'key': 'key', 'value': 'value'}], errors={} %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/follow_button.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/follow_button.html new file mode 100644 index 0000000..9dc09c9 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/ajax_snippets/follow_button.html @@ -0,0 +1 @@ +{{ h.follow_button(type, id) }} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/base.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/base.html index fc8fa09..192d8e3 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/base.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/base.html @@ -1,25 +1,13 @@ {# Allows the DOCTYPE to be set on a page by page basis #} {%- block doctype %}{% endblock -%} -{% ckan_extends %} + {# Allows custom attributes to be added to the tag #} {%- block htmltag -%} {% set lang = h.lang() %} - - - + {%- endblock -%} -{# Added by Francesco Mangiacrapa see #12286 #} -{% set my_search_string = "msgid \"Groups\"" %} -{% set translating_groups = h.d4science_get_ckan_translate_for(my_file, my_search_string) %} -{% set my_search_string = "msgid \"Datasets\"" %} -{% set translating_datasets = h.d4science_get_ckan_translate_for(my_file, my_search_string) %} -{% set my_search_string = "msgid \"Organizations\"" %} -{% set translating_organizations = h.d4science_get_ckan_translate_for(my_file, my_search_string) %} -{% set my_search_string = "msgid \"Types\"" %} -{% set translating_types = h.d4science_get_ckan_translate_for(my_file, my_search_string) %} - {# Allows custom attributes to be added to the tag #} {# @@ -36,7 +24,10 @@ #} {%- block meta -%} - {% block meta_generator %}{% endblock %} + + + + {% block meta_generator %}{% endblock %} {% block meta_viewport %}{% endblock %} {%- endblock -%} @@ -52,71 +43,10 @@ {%- block title -%} {%- block subtitle %}{% endblock -%} - {%- if self.subtitle()|trim %} {{ g.template_title_deliminater }} {% endif -%} + {%- if self.subtitle()|trim %} {{ g.template_title_delimiter }} {% endif -%} {{ g.site_title }} {%- endblock -%} - - {# The links block allows you to add additonal content before the stylesheets @@ -124,7 +54,6 @@ #} {% block links -%} - {% endblock -%} {# @@ -133,48 +62,39 @@ stylesheets before or after your own. Example: + {% block styles %} {{ super() }} - + {% endblock %} #} - - {% block styles %} - {% asset g.theme %} - {# - in ckan 2.10 fanstatic è stato rimosso. add_resource rimane lo stesso - integrato da ckan, ma resource è sostituito da "asset". - - Import d4science_theme.css using Fanstatic. - 'example_theme/' is the name that the example_theme/fanstatic directory - was registered with when the toolkit.add_resource() function was called. - 'example_theme.css' is the path to the CSS file, relative to the root of - the fanstatic directory. - #} - {{ super() }} + {%- block styles %} + {# TODO: store just name of asset instead of path to it. #} + {% set theme = h.get_rtl_theme() if h.is_rtl_language() else g.theme %} + {% asset theme %} {% asset 'd4science_theme/d4science-js' %} {% asset 'd4science_theme/d4science-css' %} - - {% endblock %} + {% endblock %} {% block head_extras %} {# defined in the config.ini under "ckan.template_head_end" #} {{ g.template_head_end | safe }} {% endblock %} + {# render all assets included in styles block #} + {{ h.render_assets('style') }} {%- block custom_styles %} {%- if g.site_custom_css -%} - + {%- endif %} {% endblock %} - {# Allows custom attributes to be added to the tag #} - -
    + + {# The page block allows you to add content to the page. Most of the time it is recommended that you extend one of the page.html templates in order to get @@ -191,7 +111,7 @@ {# DO NOT USE THIS BLOCK FOR ADDING SCRIPTS - Scripts should be loaded by the {% resource %} tag except in very special + Scripts should be loaded by the {% assets %} tag except in very special circumstances #} {%- block scripts %} @@ -201,6 +121,10 @@ {# defined in the config.ini under "ckan.template_footer_end" #} {{ g.template_footer_end | safe }} {%- endblock %} - + {# render all assets included in scripts block and everywhere else #} + {# make sure there are no calls to `asset` tag after this point #} + {{ h.render_assets('style') }} + {{ h.render_assets('script') }} + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/dataviewer/base.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/dataviewer/base.html new file mode 100644 index 0000000..9f3d714 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/dataviewer/base.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block subtitle %}{{ h.dataset_display_name(package) }} {{ g.template_title_delimiter }} {{h.resource_display_name(resource) }}{% endblock %} + +{# remove any scripts #} +{% block scripts %} + +{% endblock %} + +{# remove any ckan styles #} +{% block styles %}{% endblock %} + +{% block custom_styles %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/primer.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/primer.html new file mode 100644 index 0000000..fe4ddc8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/primer.html @@ -0,0 +1,100 @@ +{% extends "page.html" %} + +{% block toolbar %} +{% snippet 'development/snippets/breadcrumb.html', stage=1 %} +{% snippet 'development/snippets/breadcrumb.html', stage=2 %} +{% snippet 'development/snippets/breadcrumb.html', stage=3 %} +{% endblock %} + +{% block actions_content %} +{% snippet 'development/snippets/actions.html' %} +{% endblock %} + +{% block secondary_content %} +{% snippet 'development/snippets/context.html' %} + +
    +

    Helper text

    +
    {{ lipsum(1) }}
    +
    + +{% snippet 'development/snippets/nav.html', heading='Navigation' %} +{% snippet 'development/snippets/nav.html', heading='Active Navigation', show_active=true %} +{% snippet 'development/snippets/nav.html', heading='Icon Navigation', show_icons=true %} +{% snippet 'development/snippets/facet.html', heading='Facet List', show_icons=true %} +{% endblock %} + +{% block primary_content %} +{% snippet 'development/snippets/page_header.html' %} + +
    +
    + + + + +
    +
    + +
    +

    Top level heading (h1)

    +

    Some Rendered Markdown (h2)

    +
    +

    Heading 1

    + {{ lipsum(1) }} +

    Heading 2

    + {{ lipsum(1) }} +

    Heading 3

    + {{ lipsum(1) }} +
    +
    + +
    +
    +

    Forms

    +
    +{% snippet 'development/snippets/form.html' %} +{% snippet 'development/snippets/form.html', error=['This field has an error'] %} + +
    +
    +

    Form stages

    +
    +{% snippet 'development/snippets/form_stages.html' %} + + +
    +

    Datasets

    +
    + {% snippet 'snippets/package_list.html', packages=[ + {'name': "test", 'title': 'Dataset #1', 'type': 'dataset', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}}, + {'name': "test", 'title': 'Dataset #2', 'type': 'dataset', 'notes': lipsum(0), 'tracking_summary':{'recent': 5}}, + {'name': "test", 'title': 'Dataset #3', 'type': 'dataset', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}}, + {'name': "test", 'title': 'Dataset #4', 'type': 'dataset', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}} + ] %} +
    + +
    +
    +

    Media Grid

    +
    +{% snippet 'development/snippets/media_grid.html', groups=[ +{'name': "test", 'display_name': 'Group #1', 'type': 'group', 'description': lipsum(0), 'packages': 0}, +{'name': "test", 'display_name': 'Group #2', 'type': 'group', 'description': lipsum(1), 'packages': 1}, +{'name': "test", 'display_name': 'Group #3', 'type': 'group', 'description': lipsum(1), 'packages': 10}, +{'name': "test", 'display_name': 'Group #4', 'type': 'group', 'description': lipsum(1), 'packages': 200}, +{'name': "test", 'display_name': 'Group #5', 'type': 'group', 'description': lipsum(1), 'packages': 10}, +{'name': "test", 'display_name': 'Group #6', 'type': 'group', 'description': lipsum(0), 'packages': 5} +] %} + +
    +

    Pagination

    +
    +{% snippet 'development/snippets/pagination.html', total=5, current=1 %} +{% snippet 'development/snippets/pagination.html', total=5, current=3 %} +{% snippet 'development/snippets/pagination.html', total=5, current=5 %} + +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/actions.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/actions.html new file mode 100644 index 0000000..fe9519c --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/actions.html @@ -0,0 +1,2 @@ +
  • Button
  • +
  • Primary Button
  • diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/breadcrumb.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/breadcrumb.html new file mode 100644 index 0000000..9a1e371 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/breadcrumb.html @@ -0,0 +1,7 @@ +
    + +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/context.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/context.html new file mode 100644 index 0000000..936d928 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/context.html @@ -0,0 +1,26 @@ +
    +
    +
    + + + +
    +

    {{ title }}

    +

    + {{ h.markdown_extract(lipsum(1), 160) }} +

    +

    + read more +

    +
    +
    +
    Stat #1
    +
    {{ h.SI_number_span(11111) }}
    +
    +
    +
    Stat #2
    +
    {{ h.SI_number_span(111) }}
    +
    +
    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/facet.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/facet.html new file mode 100644 index 0000000..9f872cd --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/facet.html @@ -0,0 +1,15 @@ +
    + {% with items=(("First", true), ("Second", false), ("Third", true), ("Fourth", false), ("Last", false)) %} +

    Facet List

    + + + {% endwith %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form.html new file mode 100644 index 0000000..800fa0b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form.html @@ -0,0 +1,27 @@ +{% import 'macros/form.html' as form %} + +
    +
    + {{ form.input('standard', label=_('Standard'), placeholder=_('Standard Input'), value='', error=error, classes=[]) }} + {{ form.input('standard', label=_('Medium'), placeholder=_('Medium Width Input'), value='', error=error, classes=['control-medium']) }} + {{ form.input('standard', label=_('Full'), placeholder=_('Full Width Input'), value='', error=error, classes=['control-full']) }} + {{ form.input('standard', label=_('Large'), placeholder=_('Large Input'), value='', error=error, classes=['control-full', 'control-large']) }} + {{ form.prepend('slug', label=_('Prepend'), prepend='prefix', placeholder=_('Prepend Input'), value='', error=error, classes=[]) }} + {{ form.custom( + names=('custom_key', 'custom_value', 'custom_deleted'), + id='field-custom', + label=_('Custom Field (empty)'), + values=(), + error=error ) }} + {{ form.custom( + names=('custom_key', 'custom_value', 'custom_deleted'), + id='field-custom', + label=_('Custom Field'), + values=('key', 'value', true), + error=error ) }} + {{ form.markdown('desc', id='field-description', label=_('Markdown'), placeholder='Some nice placeholder text', error=error) }} + {{ form.textarea('desc', id='field-description', label=_('Textarea'), placeholder='Some nice placeholder text', error=error) }} + {{ form.select('year', label=_('Select'), options=[{'value': 2010}, {'value': 2011}], selected=2011, error=error) }} + {{ form.checkbox('remember', label="This is my checkbox", checked=true, error=error) }} +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form_stages.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form_stages.html new file mode 100644 index 0000000..409f0f5 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/form_stages.html @@ -0,0 +1,30 @@ +
    +
    + {% snippet 'package/snippets/stages.html', stages=['active'] %} +
    +
    +
    +
    + {% snippet 'package/snippets/stages.html', stages=['complete', 'active'] %} +
    +
    +
    +
    + {% snippet 'package/snippets/stages.html', stages=['complete', 'complete', 'active'] %} +
    +
    +
    +
    + {% snippet 'package/snippets/stages.html', stages=['complete', 'active', 'complete'] %} +
    +
    +
    +
    + {% snippet 'package/snippets/stages.html', stages=['active', 'complete', 'complete'] %} +
    +
    +
    +
    + {% snippet 'package/snippets/stages.html', stages=['active', 'complete'] %} +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/list.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/list.html new file mode 100644 index 0000000..48dbd87 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/list.html @@ -0,0 +1,14 @@ +
    + {% with items=(("First", false), ("Second", true), ("Third", true), ("Fourth", false), ("Last", false)) %} +

    {{ heading }}

    + + {% endwith %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/media_grid.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/media_grid.html new file mode 100644 index 0000000..0ef561f --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/media_grid.html @@ -0,0 +1,5 @@ +
    +
    + {% snippet 'group/snippets/group_list.html', groups=groups %} +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/module.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/module.html new file mode 100644 index 0000000..bc9c8e1 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/module.html @@ -0,0 +1,21 @@ +{% with classes = classes or [], hn = heading_level or 1 %} +
    + {% if heading_link %} + {{ heading }} + {% elif heading_action %} + {{ heading }} + {% elif heading_icon %} + {{ heading }} + {% else %} + {{ heading }} + {% endif %} +
    + {{ lipsum(1) }} +
    + {% if footer %} + + {% endif %} +
    +{% endwith %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/nav.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/nav.html new file mode 100644 index 0000000..da5d917 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/nav.html @@ -0,0 +1,14 @@ +
    + {% with items=(("First", true), ("Second", false), ("Third", true), ("Fourth", false), ("Last", false)) %} +

    {{ heading }}

    + + {% endwith %} +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/page_header.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/page_header.html new file mode 100644 index 0000000..cfc49c9 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/page_header.html @@ -0,0 +1,11 @@ +{% with items=(("First", true), ("Second", false), ("Third", false)) %} + +{% endwith %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/pagination.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/pagination.html new file mode 100644 index 0000000..1a375f0 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/pagination.html @@ -0,0 +1,11 @@ +
    +
    +
      + {% if current != 1 %}
    • «
    • {% endif %} + {% for index in range(1, total+1) %} + {{ index }} + {% endfor %} + {% if current != total %}
    • »
    • {% endif %} +
    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/simple-input.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/simple-input.html new file mode 100644 index 0000000..00d4d25 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/development/snippets/simple-input.html @@ -0,0 +1,4 @@ +
    +

    Module Narrow Input

    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user.txt b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user.txt new file mode 100644 index 0000000..d9deffe --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user.txt @@ -0,0 +1,19 @@ +{% trans %} +Dear {{ user_name }}, + +You have been invited to {{ site_title }}. + +A user has already been created for you with the username {{ user_name }}. You can change it later. + +You have been added to the {{ group_type }} {{ group_title }} with the following role: {{ role_name }}. + +To accept this invite, please reset your password at: + + {{ reset_link }} + + +Have a nice day. + +-- +Message sent by {{ site_title }} ({{ site_url }}) +{% endtrans %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user_subject.txt b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user_subject.txt new file mode 100644 index 0000000..1e992c1 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/invite_user_subject.txt @@ -0,0 +1,3 @@ +{% trans %} +Invite for {{ site_title }} +{% endtrans %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password.txt b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password.txt new file mode 100644 index 0000000..19936fa --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password.txt @@ -0,0 +1,14 @@ +{% trans %} +Dear {{ user_name }}, + +You have requested your password on {{ site_title }} to be reset. + +Please click the following link to confirm this request: + + {{ reset_link }} + +Have a nice day. + +-- +Message sent by {{ site_title }} ({{ site_url }}) +{% endtrans %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password_subject.txt b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password_subject.txt new file mode 100644 index 0000000..4a8941c --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/emails/reset_password_subject.txt @@ -0,0 +1,3 @@ +{% trans %} +Reset your password - {{ site_title }} +{% endtrans %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/error_document_template.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/error_document_template.html new file mode 100644 index 0000000..51acb19 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/error_document_template.html @@ -0,0 +1,31 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ gettext('Error %(error_code)s', error_code=code) }}{% endblock %} + +{% block primary %} +
    +
    + {% if name %} +

    {{ code }} {{ name }}

    + {% endif %} + {{ content}} +
    + {% block login_redirect %} + {% if show_login_redirect_link %} +
    + {{ _("You might need to login to access this page.") }} {{ _("Click here to login") }} +
    + {% endif %} + {% endblock %} +
    +{% endblock %} + +{% block breadcrumb %} +{% endblock %} + +{% block flash %} + {# eat the flash messages caused by the 404 #} + {% set flash_messages = h.get_flashed_messages() %} +{% endblock %} + +{% block secondary %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/about.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/about.html new file mode 100644 index 0000000..07461cf --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/about.html @@ -0,0 +1,16 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('About') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} + +{% block primary_content_inner %} +

    {% block page_heading %}{{ group_dict.display_name }}{% endblock %}

    + {% block group_description %} + {% if group_dict.description %} + {{ h.render_markdown(group_dict.description) }} + {% endif %} + {% endblock %} + + {% block group_extras %} + {% snippet 'snippets/additional_info.html', extras = h.sorted_extras(group_dict.extras) %} + {% endblock %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/admins.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/admins.html new file mode 100644 index 0000000..ee98a8a --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/admins.html @@ -0,0 +1,10 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('Administrators') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} + +{% block primary_content_inner %} +

    {% block page_heading %}{{ _('Administrators') }}{% endblock %}

    + {% block admins_list %} + {% snippet "user/snippets/followers.html", followers=admins %} + {% endblock %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/base_form_page.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/base_form_page.html new file mode 100644 index 0000000..5dea2ef --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/base_form_page.html @@ -0,0 +1,13 @@ +{% extends "group/edit_base.html" %} + + +{% block primary_content_inner %} +

    + {% block page_heading %} + {{ h.humanize_entity_type('group', group_type, 'form label') or _('Group Form'), }} + {% endblock %} +

    + {% block form %} + {{ form | safe }} + {% endblock %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete.html new file mode 100644 index 0000000..8c4b2e6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _("Confirm Delete") }}{% endblock %} + +{% block maintag %}
    {% endblock %} + +{% block main_content %} +
    +
    + {% block form %} +

    {{ _('Are you sure you want to delete group - {name}?').format(name=group_dict.name) }}

    +

    +

    + {{ h.csrf_input() }} + + +
    +

    + {% endblock %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete_member.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete_member.html new file mode 100644 index 0000000..4f31781 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/confirm_delete_member.html @@ -0,0 +1,23 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _("Confirm Delete") }}{% endblock %} + +{% block maintag %}
    {% endblock %} + +{% block main_content %} +
    +
    + {% block form %} +

    {{ _('Are you sure you want to delete member - {name}?').format(name=user_dict.name) }}

    +

    +

    + {{ h.csrf_input() }} + + + +
    +

    + {% endblock %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit.html new file mode 100644 index 0000000..31dcd29 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit.html @@ -0,0 +1,12 @@ +{% extends "group/base_form_page.html" %} + +{% block breadcrumb_content %} +
  • {% link_for h.humanize_entity_type('group', group_type, 'breadcrumb') or _('Groups'), named_route=group_type+'.index' %}
  • + {% block breadcrumb_content_inner %} +
  • {% link_for group.display_name|truncate(35), named_route=group_type+'.read', id=group.name, title=group.display_name %}
  • +
  • {% link_for _('Manage'), named_route=group_type+'.edit', id=group.name %}
  • + {% endblock %} +{% endblock %} + +{% block page_heading_class %}hide-heading{% endblock %} +{% block page_heading %}{{ h.humanize_entity_type('group', group_type, 'edit label') or _('Edit Group') }}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit_base.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit_base.html new file mode 100644 index 0000000..ec92493 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/edit_base.html @@ -0,0 +1,19 @@ +{% extends "page.html" %} +{% set dataset_type = h.default_package_type() %} + +{% block subtitle %}{{ _('Manage') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ h.humanize_entity_type('group', group_type, 'page title') or _('Groups') }}{% endblock %} + +{% set group = group_dict %} + +{% block content_action %} + {% link_for _('View'), named_route=group_type+'.read', id=group_dict.name, class_='btn btn-default', icon='eye' %} +{% endblock %} + +{% block content_primary_nav %} + {{ h.build_nav_icon(group_type + '.edit', _('Edit'), id=group_dict.name, icon='pencil-square') }} + {{ h.build_nav_icon(group_type + '.members', _('Members'), id=group_dict.name, icon='users') }} +{% endblock %} + +{% block secondary_content %} + {% snippet "group/snippets/info.html", group=group_dict, show_nums=false %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/followers.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/followers.html new file mode 100644 index 0000000..c51255c --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/followers.html @@ -0,0 +1,10 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('Followers') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} + +{% block primary_content_inner %} +

    {% block page_heading %}{{ _('Followers') }}{% endblock %}

    + {% block followers_list %} + {% snippet "user/snippets/followers.html", followers=followers %} + {% endblock %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/index.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/index.html new file mode 100644 index 0000000..1501bc8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/index.html @@ -0,0 +1,44 @@ +{% extends "page.html" %} + + +{% block subtitle %}{{ h.humanize_entity_type('group', group_type, 'page title') or _('Groups') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for h.humanize_entity_type('group', group_type, 'breadcrumb') or _('Groups'), named_route=group_type+'.index' %}
  • +{% endblock %} + +{% block page_header %}{% endblock %} + +{% block page_primary_action %} + {% if h.check_access('group_create') %} + {% link_for h.humanize_entity_type('group', group_type, 'add link') or _('Add Group'), named_route=group_type+'.new', class_='btn btn-primary', icon='plus-square' %} + {% endif %} +{% endblock %} + +{% block primary_content_inner %} +

    {{ h.humanize_entity_type('group', group_type, 'page title') or _('Groups') }}

    + {% block groups_search_form %} + {% snippet 'snippets/search_form.html', form_id='group-search-form', type=group_type, query=q, sorting_selected=sort_by_selected, count=page.item_count, placeholder=h.humanize_entity_type('group', group_type, 'search placeholder') or _('Search groups...'), show_empty=request.args, no_bottom_border=true if page.items, sorting = [(_('Name Ascending'), 'title asc'), (_('Name Descending'), 'title desc')] %} + {% endblock %} + {% block groups_list %} + {% if page.items or request.args %} + {% if page.items %} + {% snippet "group/snippets/group_list.html", groups=page.items %} + {% endif %} + {% else %} +

    + {{ h.humanize_entity_type('group', group_type, 'no any objects') or _('There are currently no groups for this site') }}. + {% if h.check_access('group_create') %} + {% link_for _('How about creating one?'), named_route=group_type+'.new' %}. + {% endif %} +

    + {% endif %} + {% endblock %} + {% block page_pagination %} + {{ page.pager(q=q or '', sort=sort_by_selected or '') }} + {% endblock %} +{% endblock %} + +{% block secondary_content %} + {% snippet "group/snippets/helper.html", group_type=group_type %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/member_new.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/member_new.html new file mode 100644 index 0000000..3234f71 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/member_new.html @@ -0,0 +1,101 @@ +{% extends "group/edit_base.html" %} + +{% import 'macros/form.html' as form %} + +{% set user = user_dict %} + +{% block primary_content_inner %} + {% link_for _('Back to all members'), named_route=group_type+'.members', id=group.name, class_='btn btn-default pull-right', icon='arrow-left' %} +

    + {% block page_heading %}{{ _('Edit Member') if user else _('Add Member') }}{% endblock %} +

    + {% block form %} +
    + {{ h.csrf_input() }} +
    +
    +
    + {% if not user %} + +

    + {{ _('If you wish to add an existing user, search for their username below.') }} +

    + {% endif %} +
    + {% if user %} + + + {% else %} + + {% endif %} +
    +
    +
    + {% if not user %} +
    +
    + {{ _('or') }} +
    +
    +
    +
    + +

    + {{ _('If you wish to invite a new user, enter their email address.') }} +

    +
    + +
    +
    +
    + {% endif %} +
    + + {% if user and user.name == c.user and user_role == 'admin' %} + {% set format_attrs = {'data-module': 'autocomplete', 'disabled': 'disabled'} %} + {{ form.select('role', label=_('Role'), options=roles, selected=user_role, error='', attrs=format_attrs) }} + {{ form.hidden('role', value=user_role) }} + {% else %} + {% set format_attrs = {'data-module': 'autocomplete'} %} + {{ form.select('role', label=_('Role'), options=roles, selected=user_role, error='', attrs=format_attrs) }} + {% endif %} + +
    + {% if user %} + {{ _('Delete') }} + + {% else %} + + {% endif %} +
    +
    + {% endblock %} +{% endblock %} + +{% block secondary_content %} + {{ super() }} +
    +

    + + {{ _('What are roles?') }} +

    +
    + {% trans %} +

    Admin: Can edit group information, as well as + manage organization members.

    +

    Member: Can add/remove datasets from groups

    + {% endtrans %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/members.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/members.html new file mode 100644 index 0000000..31d09ef --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/members.html @@ -0,0 +1,38 @@ +{% extends "group/edit_base.html" %} + +{% block subtitle %}{{ _('Members') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ h.humanize_entity_type('group', group_type, 'page title') or _('Groups') }}{% endblock %} + +{% block page_primary_action %} + {% link_for _('Add Member'), named_route=group_type+'.member_new', id=group_dict.id, class_='btn btn-primary', icon='plus-square' %} +{% endblock %} + +{% block primary_content_inner %} +

    {{ _('{0} members'.format(members|length)) }}

    + + + + + + + + + + {% for user_id, user, role in members %} + + + + + + {% endfor %} + +
    {{ _('User') }}{{ _('Role') }}
    + {{ h.linked_user(user_id, maxlength=20) }} + {{ role }} + +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new.html new file mode 100644 index 0000000..69230a3 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new.html @@ -0,0 +1,25 @@ +{% extends "group/base_form_page.html" %} + +{% set label = h.humanize_entity_type('group', group_type, 'create title') or _('Create a Group') %} + + +{% block subtitle %}{{ label }}{% endblock %} + +{% block page_heading %}{{ label }}{% endblock %} + +{% block breadcrumb_content %} +
  • {{ h.nav_link( + h.humanize_entity_type('group', group_type, 'breadcrumb') or _('Groups'), + named_route=group_type+'.index') }}
  • +
  • + {{ h.nav_link( + label, + named_route=group_type~'.new') }} +
  • +{% endblock %} + +{% block page_header %}{% endblock %} + +{% block secondary_content %} + {% snippet "group/snippets/helper.html", group_type=group_type %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new_group_form.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new_group_form.html new file mode 100644 index 0000000..7fb4ccd --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/new_group_form.html @@ -0,0 +1,25 @@ +{% extends "group/snippets/group_form.html" %} + +{# + As the form is rendered as a seperate page we take advantage of this by + overriding the form blocks depending on the current context + #} +{% block dataset_fields %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} + +{% block custom_fields %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} + +{% block save_text %} + {%- if action == "edit" -%} + {{ h.humanize_entity_type('group', group_type, 'update label') or _('Update Group') }} + {%- else -%} + {{ h.humanize_entity_type('group', group_type, 'create label') or _('Create Group') }} + {%- endif -%} +{% endblock %} + +{% block delete_button %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read.html new file mode 100644 index 0000000..ff69aa7 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read.html @@ -0,0 +1,42 @@ +{% extends "group/read_base.html" %} +{% set dataset_type = h.default_package_type() %} + +{% block primary_content_inner %} + {% block groups_search_form %} + {% set facets = { + 'fields': fields_grouped, + 'search': search_facets, + 'titles': facet_titles, + 'translated_fields': translated_fields, + 'remove_field': remove_field } + %} + {% set sorting = [ + (_('Relevance'), 'score desc, metadata_modified desc'), + (_('Name Ascending'), 'title_string asc'), + (_('Name Descending'), 'title_string desc'), + (_('Last Modified'), 'metadata_modified desc'), + (_('Popular'), 'views_recent desc') if g.tracking_enabled else (false, false) ] + %} + {% snippet 'snippets/search_form.html', form_id='group-datasets-search-form', type=dataset_type, query=q, sorting=sorting, sorting_selected=sort_by_selected, count=page.item_count, facets=facets, placeholder=h.humanize_entity_type('package', dataset_type, 'search placeholder') or _('Search datasets...'), show_empty=request.args, fields=fields %} + {% endblock %} + {% block packages_list %} + {% if page.items %} + {{ h.snippet('snippets/package_list.html', packages=page.items) }} + {% endif %} + {% endblock %} + {% block page_pagination %} + {{ page.pager(q=q) }} + {% endblock %} +{% endblock %} + +{% block secondary_content %} + {{ super() }} +
    +
    + {% for facet in facet_titles %} + {{ h.snippet('snippets/facet_list.html', title=facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets) }} + {% endfor %} +
    + close +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read_base.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read_base.html index 6e29fae..75c869b 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read_base.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/read_base.html @@ -1,32 +1,26 @@ {% extends "page.html" %} +{% set dataset_type = h.default_package_type() %} -{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ h.humanize_entity_type('group', group_type, 'page title') or _('Groups') }}{% endblock %} {% block breadcrumb_content %} -
  • {% link_for _('Groups'), controller='group', action='index' %}
  • -
  • {% link_for c.group_dict.display_name|truncate(35), controller='group', action='read', id=c.group_dict.name %}
  • +
  • {% link_for h.humanize_entity_type('group', group_type, 'breadcrumb') or _('Groups'), named_route=group_type+'.index' %}
  • +
  • {% link_for group_dict.display_name|truncate(35), named_route=group_type+'.read', id=group_dict.name, title=group_dict.display_name %}
  • {% endblock %} {% block content_action %} - {% if h.check_access('group_update', {'id': c.group_dict.id}) %} - {% link_for _('Manage'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} + {% if h.check_access('group_update', {'id': group_dict.id}) %} + {% link_for _('Manage'), named_route=group_type+'.edit', id=group_dict.name, class_='btn btn-default', icon='wrench' %} {% endif %} {% endblock %} {% block content_primary_nav %} - {{ h.build_nav_icon('group_read', _('Datasets'), id=c.group_dict.name) }} - - {% if h.check_access('group_update', {'id': c.group_dict.id}) %} - {% set role_for_user = h.d4science_theme_get_user_role_for_group_or_org(c.group_dict.id, c.userobj.name) %} - {% if (role_for_user and role_for_user == 'admin') or c.userobj.sysadmin %} - {{ h.build_nav_icon('group_activity', _('Activity Stream'), id=c.group_dict.name, offset=0) }} - {% endif %} - {% endif %} - {{ h.build_nav_icon('group_about', _('About'), id=c.group_dict.name) }} + {{ h.build_nav_icon(group_type + '.read', h.humanize_entity_type('package', dataset_type, 'content tab') or _('Datasets'), id=group_dict.name, icon='sitemap') }} + {{ h.build_nav_icon(group_type + '.about', _('About'), id=group_dict.name, icon='info-circle') }} {% endblock %} {% block secondary_content %} - {% snippet "group/snippets/info.html", group=c.group_dict, show_nums=true %} + {% snippet "group/snippets/info.html", group=group_dict, show_nums=true %} {% endblock %} {% block links %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/feeds.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/feeds.html new file mode 100644 index 0000000..26de3a6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/feeds.html @@ -0,0 +1,2 @@ +{%- set dataset_feed = h.url_for('feeds.group', id=group_dict.name) -%} + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_form.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_form.html index e126fca..2e752b5 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_form.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_form.html @@ -1,64 +1,25 @@ {% import 'macros/form.html' as form %} -{# SCRIPT ADDED BY FRACESCO MANGIACRAPA, SEE 20425 #} - - -
    + + {{ h.csrf_input() }} {% block error_summary %} {{ form.errors(error_summary) }} {% endblock %} {% block basic_fields %} - {% set attrs = {'data-module': 'slug-preview-target'} %} - {# DIV ADDED BY FRACESCO MANGIACRAPA, SEE 20425 #} -
    - {{ form.input('title', label=_('Name'), id='field-name', placeholder=_('My Group'), value=data.title, error=errors.title, classes=['control-full'], attrs=attrs) }} -
    - {# Perhaps these should be moved into the controller? #} - {% set prefix = h.url_for(controller='group', action='read', id='') %} - {% set domain = h.url_for(controller='group', action='read', id='', qualified=true) %} - {% set domain = domain|replace("http://", "")|replace("https://", "") %} - {% set attrs = {'data-module': 'slug-preview-slug', 'data-module-prefix': domain, 'data-module-placeholder': ''} %} - - {# CODE ADDED BY FRACESCO MANGIACRAPA, SEE 20425 #} - {% set hide = h.get_cookie_value('ckan_hide_header') %} - {% if hide=='true' %} - - {% endif %} + {% set attrs = {'data-module': 'slug-preview-target', 'class': 'form-control'} %} + {{ form.input('title', label=_('Name'), id='field-name', placeholder=h.humanize_entity_type('group', group_type, 'name placeholder') or _('My Group'), value=data.title, error=errors.title, classes=['control-full'], attrs=attrs) }} - {{ form.prepend('name', label=_('URL'), prepend=prefix, id='field-url', placeholder=_('my-group'), value=data.name, error=errors.name, attrs=attrs, is_required=true) }} - - {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my group...'), value=data.description, error=errors.description) }} + {# Perhaps these should be moved into the controller? #} + {% set prefix = h.url_for(group_type + '.read', id='') %} + {% set domain = h.url_for(group_type + '.read', id='', qualified=true) %} + {% set domain = domain|replace("http://", "")|replace("https://", "") %} + {% set attrs = {'data-module': 'slug-preview-slug', 'class': 'form-control input-sm', 'data-module-prefix': domain, 'data-module-placeholder': '<' + group_type + '>'} %} + + {{ form.prepend('name', label=_('URL'), prepend=prefix, id='field-url', placeholder=_('my-' + group_type), value=data.name, error=errors.name, attrs=attrs, is_required=true) }} + + {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=h.humanize_entity_type('group', group_type, 'description placeholder') or _('A little information about my group...'), value=data.description, error=errors.description) }} {% set is_upload = data.image_url and not data.image_url.startswith('http') %} {% set is_url = data.image_url and data.image_url.startswith('http') %} @@ -68,29 +29,7 @@ if(window.addEventListener){ {% endblock %} {% block custom_fields %} - {% for extra in data.extras %} - {% set prefix = 'extras__%d__' % loop.index0 %} - {{ form.custom( - names=(prefix ~ 'key', prefix ~ 'value', prefix ~ 'deleted'), - id='field-extras-%d' % loop.index, - label=_('Custom Field'), - values=(extra.key, extra.value, extra.deleted), - error=errors[prefix ~ 'key'] or errors[prefix ~ 'value'] - ) }} - {% endfor %} - - {# Add a max if 3 empty columns #} - {% for extra in range(data.extras|count, 3) %} - {% set index = (loop.index0 + data.extras|count) %} - {% set prefix = 'extras__%d__' % index %} - {{ form.custom( - names=(prefix ~ 'key', prefix ~ 'value', prefix ~ 'deleted'), - id='field-extras-%d' % index, - label=_('Custom Field'), - values=(extra.key, extra.value, extra.deleted), - error=errors[prefix ~ 'key'] or errors[prefix ~ 'value'] - ) }} - {% endfor %} + {% snippet 'snippets/custom_form_fields.html', extras=data.extras, errors=errors, limit=3 %} {% endblock %} {{ form.required_message() }} @@ -98,10 +37,9 @@ if(window.addEventListener){
    {% block delete_button %} {% if h.check_access('group_delete', {'id': data.id}) %} - {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} - {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} {% endif %} {% endblock %} - +
    - \ No newline at end of file + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_item.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_item.html index 3f9e5ad..1908ca6 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_item.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_item.html @@ -11,51 +11,36 @@ Example: {% endfor %} #} - - -{# Updated By Francesco Mangiacrapa, related to breadcrumb for Group #} {% set type = group.type or 'group' %} -{% set url = h.url_for(type ~ '_read', action='read', id=group.name) %} +{% set url = h.url_for(type ~ '.read', id=group.name) %} {% block item %}
  • - {% block breadcrumb %} -
    - {% set parents = h.d4science_theme_get_parents_for_group(group.name) %} - {% for parent in parents recursive %} - - {% link_for parent.title, controller='group', action='read', id=parent.name %} - {% if loop.last == False %} - {% endif %} - - {% endfor %} -
    - {% endblock %} {% block item_inner %} {% block image %} - {{ group.name }} + {{ group.name }} {% endblock %} {% block title %} -

    {{ group.display_name }}

    +

    {{ group.display_name }}

    {% endblock %} {% block description %} {% if group.description %} -

    {{ h.d4science_theme_markdown_extract_html(group.description, extract_length=80, allow_html=True) }}

    +

    {{ h.markdown_extract(group.description, extract_length=80) }}

    {% endif %} {% endblock %} {% block datasets %} {% if group.package_count %} {{ ungettext('{num} Dataset', '{num} Datasets', group.package_count).format(num=group.package_count) }} {% elif group.package_count == 0 %} - + {{ _('0 Datasets') }} {% endif %} {% endblock %} + {% block link %} + + {{ _('View {name}').format(name=group.display_name) }} + + {% endblock %} {% if group.user_member %} - {# Added By Francesco Mangiacrapa, related to Task #5196 #} - {% set username_id = c.userobj.id %} - {% set role_for_user = h.d4science_theme_get_user_role_for_group_or_org(group.id, username_id) %} - {% if role_for_user and role_for_user == 'admin' %} - - {% endif %} + {% endif %} {% endblock %}
  • diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_list.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_list.html index ab7e73e..d171296 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_list.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/group_list.html @@ -1,4 +1,4 @@ -{# CREATED BY FRANCESCO MANGIACRAPA +{# Display a grid of group items. groups - A list of groups. @@ -8,52 +8,11 @@ Example: {% snippet "group/snippets/group_list.html" %} #} - - - -{# -Display a hierarchical tree of groups - - -Example: - - {% snippet "group/snippets/group_tree.html" %} - -#} - -{# CKAN 2.6.X #} -{% set top_nodes = h.group_tree(type_='group') %} -{# CKAN 2.5.X -{% set top_nodes=h.get_action('group_tree', {'type': 'group'}) %} -#} - - - {% block group_list %}
      {% block group_list_inner %} {% for group in groups %} - {% snippet "group/snippets/group_item.html", group=group, position=loop.index, top_nodes=top_nodes %} + {% snippet "group/snippets/group_item.html", group=group, position=loop.index %} {% endfor %} {% endblock %}
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/helper.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/helper.html new file mode 100644 index 0000000..7061646 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/helper.html @@ -0,0 +1,27 @@ +{# + Displays a sidebard module with information about group. + + group_type - The type of group. + + Example: + + {% snippet "group/snippets/helper.html", group_type=group_type %} + + #} + +
    +

    + + {{ _('What are Groups?') }} +

    +
    +

    + {% trans %} + You can use CKAN Groups to create and manage collections of datasets. + This could be to catalogue datasets for a particular project or team, + or on a particular theme, or as a very simple way to help people find + and search your own published datasets. + {% endtrans %} +

    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/info.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/info.html new file mode 100644 index 0000000..5916124 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/group/snippets/info.html @@ -0,0 +1,54 @@ +{% set dataset_type = h.default_package_type() %} + +{% block info %} +
    +
    + {% block inner %} + {% block image %} +
    + + {{ group.name }} + +
    + {% endblock %} + {% block heading %} +

    + {{ group.display_name }} + {% if group.state == 'deleted' %} + [{{ _('Deleted') }}] + {% endif %} +

    + {% endblock %} + {% block description %} + {% if group.description %} +

    + {{ h.markdown_extract(group.description, 180) }} +

    +

    + {% link_for _('read more'), named_route='group.about', id=group.name %} +

    + {% endif %} + {% endblock %} + {% if show_nums %} + {% block nums %} +
    +
    +
    {{ _('Followers') }}
    +
    {{ h.SI_number_span(group.num_followers) }}
    +
    +
    +
    {{ h.humanize_entity_type('package', dataset_type, 'facet label') or _('Datasets') }}
    +
    {{ h.SI_number_span(group.package_count) }}
    +
    +
    + {% endblock %} + {% block follow %} + + {% endblock %} + {% endif %} + {% endblock %} +
    +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/header.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/header.html index 6e24cd2..b3b1f17 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/header.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/header.html @@ -1,144 +1,123 @@ -{# FOLLOWING BLOCK IS ADDED BY FRANCESCO MANGIACRAPA #} -{% block header_hide_container_content %} -{% set hide = h.get_cookie_value('ckan_hide_header') %} -{% if hide=='true' %} - {# HIDE HEADER #} -{% else %} -{% block header_wrapper %} -{% block header_account %} - - -{% endblock %} -
    {% endblock %} -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/about.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/about.html new file mode 100644 index 0000000..8ed25d8 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/about.html @@ -0,0 +1,24 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _('About') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _('About'), named_route='home.about' %}
  • +{% endblock %} + +{% block primary %} +
    +
    + {% block about %} + {% if g.site_about %} + {{ h.render_markdown(g.site_about) }} + {% else %} +

    {{ _('About') }}

    + {% snippet 'home/snippets/about_text.html' %} + {% endif %} + {% endblock %} +
    +
    +{% endblock %} + +{% block secondary %}{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/index.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/index.html index 4e308a5..c14d4e0 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/index.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/index.html @@ -1 +1,18 @@ -{% ckan_extends %} \ No newline at end of file +{% extends "page.html" %} +{% set homepage_style = ( g.homepage_style or '1' ) %} + +{% block subtitle %}{{ _("Welcome") }}{% endblock %} + +{% block maintag %}{% endblock %} +{% block toolbar %}{% endblock %} + +{% block content %} +
    +
    + {{ self.flash() }} +
    + {% block primary_content %} + {% snippet "home/layout{0}.html".format(homepage_style), search_facets=search_facets %} + {% endblock %} +
    +{% endblock %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout1.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout1.html new file mode 100644 index 0000000..60c129d --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout1.html @@ -0,0 +1,40 @@ +
    +
    +
    +
    +
    + {% block promoted %} + {% snippet 'home/snippets/promoted.html' %} + {% endblock %} +
    +
    + {% block search %} + {% snippet 'home/snippets/search.html', search_facets=search_facets %} + {% endblock %} +
    +
    +
    +
    + +
    +
    +
    +
    + {# Note: this featured_group block is used as an example in the theming + tutorial in the docs! If you change this code, be sure to check + whether you need to update the docs. #} + {# Start template block example. #} + {% block featured_group %} + {% snippet 'home/snippets/featured_group.html' %} + {% endblock %} + {# End template block example. #} +
    +
    + {% block featured_organization %} + {% snippet 'home/snippets/featured_organization.html' %} + {% endblock %} +
    +
    +
    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout2.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout2.html index 047d6aa..68beb5d 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout2.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout2.html @@ -1,108 +1,35 @@ - - - - -
    -
    -
    - {% block promoted %} - {% snippet 'home/snippets/promoted.html' %} - {% endblock %} -
    -
    -
    -
    -
    -
    +
    +
    {% block search %} - {% snippet 'home/snippets/search.html' %} + {% snippet 'home/snippets/search.html', search_facets=search_facets %} {% endblock %} -
    -
    - {% block stats %} + {% block stats %} {% snippet 'home/snippets/stats.html' %} {% endblock %}
    -
    -
    -
    - - - - - -{% block search_for_organizations %} -{# Added by Francesco Mangiacrapa, see: #8964 #} - - - {% snippet 'home/snippets/search_for_organisations.html' %} -{% endblock %} - -{% block search_for_groups %} - {% snippet 'home/snippets/search_for_groups.html' %} -{% endblock %} - -{% block search_for_types %} - {% snippet 'home/snippets/search_for_types.html' %} -{% endblock %} - - -{# - {% block search_for_location %} - {% snippet 'home/snippets/search_for_location.html' %} - {% endblock %} - -#} - - - - +
    +
    +
    +
    + {% block featured_organization %} + {% snippet 'home/snippets/featured_organization.html' %} + {% endblock %} +
    +
    + {% block featured_group %} + {% snippet 'home/snippets/featured_group.html' %} + {% endblock %} +
    -
    - {% block popular_tags %} - {% snippet 'home/snippets/popular_tags.html' %} - {% endblock %} -
    - {% block recent_activity %} - - {% if c.userobj and c.userobj.sysadmin %} -
    -
    -

    Recent activity

    -
    -
    - {{ h.recently_changed_packages_activity_stream(limit=4) }} -
    -
    - {%endif%} - {% endblock %}
    - diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout3.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout3.html new file mode 100644 index 0000000..fc9d98b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/layout3.html @@ -0,0 +1,23 @@ +
    +
    + {% block search %} + {% snippet 'home/snippets/search.html', search_facets=search_facets %} + {% endblock %} +
    +
    +
    +
    +
    +
    + {% block promoted %} + {% snippet 'home/snippets/promoted.html' %} + {% endblock %} +
    +
    + {% block stats %} + {% snippet 'home/snippets/stats.html' %} + {% endblock %} +
    +
    +
    +
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/robots.txt b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/robots.txt new file mode 100644 index 0000000..ca60362 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/robots.txt @@ -0,0 +1,12 @@ +User-agent: * +{% block all_user_agents -%} +Disallow: /dataset/rate/ +Disallow: /revision/ +Disallow: /dataset/*/history +Disallow: /api/ +Crawl-Delay: 10 +{%- endblock %} + +{% block additional_user_agents -%} +{%- endblock %} + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/about_text.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/about_text.html new file mode 100644 index 0000000..21deb89 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/about_text.html @@ -0,0 +1,20 @@ +{% trans %} +

    CKAN is the world’s leading open-source data portal platform.

    + +

    CKAN is a complete out-of-the-box software solution that makes data +accessible and usable – by providing tools to streamline publishing, sharing, +finding and using data (including storage of data and provision of robust data +APIs). CKAN is aimed at data publishers (national and regional governments, +companies and organizations) wanting to make their data open and available.

    + +

    CKAN is used by governments and user groups worldwide and powers a variety +of official and community data portals including portals for local, national +and international government, such as the UK’s data.gov.uk and the +United States catalog.data.gov, the Brazilian dados.gov.br, Dutch and +Netherland government portals, as well as city and municipal sites in the US, +UK, Argentina, Finland and elsewhere.

    + +

    CKAN: https://ckan.org/
    +CKAN Showcases: https://ckan.org/showcase
    +Features overview: https://ckan.org/features/

    +{% endtrans %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_group.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_group.html new file mode 100644 index 0000000..f411c1e --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_group.html @@ -0,0 +1,7 @@ +{% set groups = h.get_featured_groups() %} + +{% for group in groups %} +
    + {% snippet 'snippets/group_item.html', group=group %} +
    +{% endfor %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_organization.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_organization.html index 7cf9ddb..d13d18e 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_organization.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/featured_organization.html @@ -2,6 +2,6 @@ {% for organization in organizations %}
    - {% snippet 'snippets/organization_item.html', organization=organization, truncate=50, truncate_title=35 %} + {% snippet 'snippets/organization_item.html', organization=organization %}
    {% endfor %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/promoted.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/promoted.html index ab4dcd3..9503571 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/promoted.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/promoted.html @@ -1,27 +1,27 @@ {% set intro = g.site_intro_text %} -
    -
    +
    +
    {% if intro %} {{ h.render_markdown(intro) }} {% else %} - {% block home_image %} -
    + + {% block home_image %} + {% endblock %} -
    -

    Welcome to the {{g.site_title}}!

    -

    -Here you will find data and other resources hosted by the D4Science.org infrastructure.

    -

    The catalogue contains a wealth of resources resulting from several activities, projects and communities including BlueBRIDGE (www.bluebridge-vres.eu), i-Marine (www.i-marine.eu), SoBigData.eu (www.sobigdata.eu), and (FAO www.fao.org). -

    -

    All the products are accompanied with rich descriptions capturing general attributes, e.g. title and creator(s), as well as usage policies and licences.

    -
    - {% endif %} -
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/search.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/search.html index 2835041..89efb0c 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/search.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/search.html @@ -1,26 +1,22 @@ -{#{% set tags = h.get_facet_items_dict('tags', limit=3) %}#} -{% set placeholder = _('Insert keywords here') %} +{% set tags = h.get_facet_items_dict('tags', search_facets, limit=3) %} +{% set placeholder = _('E.g. environment') %} +{% set dataset_type = h.default_package_type() %} -
    diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/stats.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/stats.html index be54cac..c70d596 100644 --- a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/stats.html +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/home/snippets/stats.html @@ -1,42 +1,28 @@ {% set stats = h.get_site_statistics() %} -{% set systemtypefacet = h.d4science_theme_get_systemtype_field_dict_from_session() %} -{% set count_systemtypefacet = h.d4science_theme_count_facet_items_dict(systemtypefacet['name']) %} -
    -
    +
    + diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/autoform.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/autoform.html new file mode 100644 index 0000000..c469b81 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/autoform.html @@ -0,0 +1,70 @@ +{# +Builds a form from the supplied form_info list/tuple. All form info dicts +can also contain an "extra_info" key which will add some help text after the +input element. + +form_info - A list of dicts describing the form field to build. +data - The form data object. +errors - The form errors object. +error_summary - A list of errors to display above the fields. + +Example + + {% set form_info = [ + {'name': 'ckan.site_title', 'control': 'input', 'label': _('Site Title'), 'placeholder': ''}, + {'name': 'ckan.theme', 'control': 'select', 'options': styles, 'label': _('Style'), 'placeholder': ''}, + {'name': 'ckan.site_description', 'control': 'input', 'label': _('Site Tag Line'), 'placeholder': ''}, + {'name': 'ckan.site_logo', 'control': 'input', 'label': _('Site Tag Logo'), 'placeholder': ''}, + {'name': 'ckan.site_about', 'control': 'markdown', 'label': _('About'), 'placeholder': _('About page text')}, + {'name': 'ckan.site_intro_text', 'control': 'markdown', 'label': _('Intro Text'), 'placeholder': _('Text on home page')}, + {'name': 'ckan.site_custom_css', 'control': 'textarea', 'label': _('Custom CSS'), 'placeholder': _('Customisable css inserted into the page header')}, + ] %} + + {% import 'macros/autoform.html' as autoform %} + {{ autoform.generate(form_info, data, errors) }} + +#} +{% import 'macros/form.html' as form %} +{%- macro generate(form_info=[], data={}, errors={}, error_summary=[]) -%} + {{ form.errors(error_summary) if error_summary }} + + {% for item in form_info %} + {% set name = item.name %} + {% set value = data.get(name) %} + {% set error = errors.get(name) %} + {% set id = 'field-%s' % (name|lower|replace('_', '-')|replace('.', '-')) %} + + {% set control = item.control or 'input' %} + {% set label = item.label %} + {% set placeholder = item.placeholder %} + + {% set classes = item.classes or [] %} + {% set classes = ['control-medium'] if not classes and control == 'input' %} + + {% if control == 'select' %} + {% call form.select(name, id=id, label=label, options=item.options, selected=value, error=error) %} + {% if item.extra_info %}{{ form.info(item.extra_info) }}{% endif %} + {% endcall %} + {% elif control == 'html' %} +
    +
    + {{ item.html }} +
    +
    + {% elif control == 'image_upload' %} + {% set field_url = item.field_url or 'image_url' %} + {% set is_upload = data[field_url] and not data[field_url].startswith('http') %} + {% set is_url = data[field_url] and data[field_url].startswith('http') %} + + {% set field_upload = item.field_upload or 'image_upload' %} + {% set field_clear = item.field_clear or 'clear_upload' %} + + {{ form.image_upload(data, errors, is_upload_enabled=item.upload_enabled, is_url=is_url, is_upload=is_upload, upload_label = _('Site logo'), url_label=_('Site logo'), + field_url=field_url, field_upload=field_upload, field_clear=field_clear)}} + {% else %} + {% call form[control](name, id=id, label=label, placeholder=placeholder, value=value, error=error, classes=classes) %} + {% if item.extra_info %}{{ form.info(item.extra_info) }}{% endif %} + {% endcall %} + {% endif %} + {% endfor %} +{%- endmacro -%} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form.html new file mode 100644 index 0000000..f33b7ab --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form.html @@ -0,0 +1,36 @@ +{# +All macros were split into their own template file in `templates/macros/form/`) +and here, we are importing them all to maintain backward compatibility. +#} + +{% from 'macros/form/input.html' import input %} +{% from "macros/form/input_block.html" import input_block %} +{% from 'macros/form/checkbox.html' import checkbox %} +{% from 'macros/form/select.html' import select %} +{% from "macros/form/attributes.html" import attributes %} +{% from "macros/form/markdown.html" import markdown %} +{% from "macros/form/textarea.html" import textarea %}s +{% from "macros/form/prepend.html" import prepend %} +{% from "macros/form/custom.html" import custom %} +{% from "macros/form/errors.html" import errors %} +{% from "macros/form/info.html" import info %} +{% from "macros/form/hidden.html" import hidden %} +{% from "macros/form/hidden_from_list.html" import hidden_from_list %} +{% from "macros/form/required_message.html" import required_message %} +{% from "macros/form/image_upload.html" import image_upload %} + +{% set input = input %} +{% set input_block = input_block %} +{% set checkbox = checkbox %} +{% set select = select %} +{% set attributes = attributes %} +{% set markdown = markdown %} +{% set textarea = textarea %} +{% set prepend = prepend %} +{% set custom = custom %} +{% set errors = errors %} +{% set info = info %} +{% set hidden = hidden %} +{% set hidden_from_list = hidden_from_list %} +{% set required_message = required_message %} +{% set image_upload = image_upload %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/attributes.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/attributes.html new file mode 100644 index 0000000..47359a6 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/attributes.html @@ -0,0 +1,17 @@ +{# +Builds a space seperated list of html attributes from a dict of key/value pairs. +Generally only used internally by macros. + +attrs - A dict of attribute/value pairs + +Example + +{% import 'macros/form.html' as form %} +{{ form.attributes({}) }} + +#} +{%- macro attributes(attrs={}) -%} +{%- for key, value in attrs.items() -%} +{{ " " }}{{ key }}{% if value != "" %}="{{ value }}"{% endif %} +{%- endfor -%} +{%- endmacro -%} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/checkbox.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/checkbox.html new file mode 100644 index 0000000..130720f --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/checkbox.html @@ -0,0 +1,34 @@ +{% from "macros/form/attributes.html" import attributes %} + +{# +Builds a single checkbox input. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +value - The value of the input. +checked - If true the checkbox will be checked +error - An error string for the field or just true to highlight the field. +classes - An array of classes to apply to the form-group. +is_required - Boolean of whether this input is requred for the form to validate + +Example: + +{% import 'macros/form.html' as form %} +{{ form.checkbox('remember', checked=true) }} + +#} +{% macro checkbox(name, id='', label='', value='', checked=false, placeholder='', error="", classes=[], attrs={}, is_required=false) %} +{%- set extra_html = caller() if caller -%} +
    +
    + + {{ extra_html }} +
    +
    +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/custom.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/custom.html new file mode 100644 index 0000000..4503b05 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/custom.html @@ -0,0 +1,61 @@ +{% from "macros/form/input_block.html" import input_block %} +{% from "macros/form/attributes.html" import attributes %} + +{# +Creates all the markup required for an custom key/value input. These are usually +used to let the user provide custom meta data. Each "field" has three inputs +one for the key, one for the value and a checkbox to remove it. So the arguments +for this macro are nearly all tuples containing values for the +(key, value, delete) fields respectively. + +name - A tuple of names for the three fields. +id - An id string to be used for each input. +label - The human readable label for the main label. +values - A tuple of values for the (key, value, delete) fields. If delete +is truthy the checkbox will be checked. +placeholder - A tuple of placeholder text for the (key, value) fields. +error - A list of error strings for the field or just true to highlight the field. +classes - An array of classes to apply to the form-group. +is_required - Boolean of whether this input is requred for the form to validate + +Examples: + +{% import 'macros/form.html' as form %} +{{ form.custom( +names=('custom_key', 'custom_value', 'custom_deleted'), +id='field-custom', +label=_('Custom Field'), +values=(extra.key, extra.value, extra.deleted), +error='' +) }} +#} +{% macro custom(names=(), id="", label="", values=(), placeholders=(), error="", classes=[], attrs={}, is_required=false, key_values=()) %} +{%- set classes = (classes|list) -%} +{%- set label_id = (id or names[0]) ~ "-key" -%} +{%- set extra_html = caller() if caller -%} +{%- do classes.append('control-custom') -%} + +{% call input_block(label_id, label or name, error, classes, control_classes=["editor"], extra_html=extra_html, is_required=is_required) %} +
    +
    +
    + + +
    +
    +
    + {% if values[0] or values[1] or error %} + + {% endif %} +
    + + +
    +
    +
    + +{% endcall %} +{% endmacro %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/errors.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/errors.html new file mode 100644 index 0000000..9f55bad --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/errors.html @@ -0,0 +1,26 @@ +{# +Builds a list of errors for the current form. + +errors - A dict of field/message pairs. +type - The alert-* class that should be applied (default: "error") +classes - A list of classes to apply to the wrapper (default: []) + +Example: + +{% import 'macros/form.html' as form %} +{{ form.errors(error_summary, type="warning") }} + +#} + +{% macro errors(errors={}, type="error", classes=[]) %} +{% if errors %} +
    +

    {{ _('The form contains invalid entries:') }}

    +
      + {% for key, error in errors.items() %} +
    • {% if key %}{{ key }}: {% endif %}{{ error }}
    • + {% endfor %} +
    +
    +{% endif %} +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden.html new file mode 100644 index 0000000..ad46447 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden.html @@ -0,0 +1,14 @@ +{# +Builds a single hidden input. + +name - name of the hidden input +value - value of the hidden input + +Example +{% import 'macros/form.html' as form %} +{{ form.hidden('name', 'value') }} + +#} +{% macro hidden(name, value) %} + +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden_from_list.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden_from_list.html new file mode 100644 index 0000000..60e0d16 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/hidden_from_list.html @@ -0,0 +1,28 @@ +{% from "macros/form/hidden.html" import hidden %} + +{# +Contructs hidden inputs for each name-value pair. + +fields - [('name1', 'value1'), ('name2', 'value2'), ...] + +Two parameter for excluding several names or name-value pairs. + +except_names - list of names to be excluded +except - list of name-value pairs to be excluded + + +Example: +{% import 'macros/form.html' as form %} +{% form.hidden_from_list(fields=c.fields, except=[('topic', 'xyz')]) %} +{% form.hidden_from_list(fields=c.fields, except_names=['time_min', 'time_max']) %} +#} +{% macro hidden_from_list(fields, except_names=None, except=None) %} +{% set except_names = except_names or [] %} +{% set except = except or [] %} + +{% for name, value in fields %} +{% if name and value and name not in except_names and (name, value) not in except %} +{{ hidden(name, value) }} +{% endif %} +{% endfor %} +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/image_upload.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/image_upload.html new file mode 100644 index 0000000..1f35a0b --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/image_upload.html @@ -0,0 +1,59 @@ +{% from 'macros/form/input.html' import input %} +{% from 'macros/form/checkbox.html' import checkbox %} + +{# +Builds a file upload for input + +Example +{% import 'macros/form.html' as form %} +{{ form.image_upload(data, errors, is_upload_enabled=true) }} + +#} +{% macro image_upload(data, errors, field_url='image_url', field_upload='image_upload', field_clear='clear_upload', + is_url=false, is_upload=false, is_upload_enabled=false, placeholder=false, + url_label='', upload_label='', field_name='image_url') %} +{% set placeholder = placeholder if placeholder else _('http://example.com/my-image.jpg') %} +{% set url_label = url_label or _('Image URL') %} +{% set upload_label = upload_label or _('Image') %} +{% set previous_upload = data['previous_upload'] %} + +{% if field_url == 'url' and field_upload == 'upload' %} + {# backwards compatibility for old resource forms that still call the `forms.image_upload()` macro, eg ckanext-scheming #} + {% snippet 'package/snippets/resource_upload_field.html', + data=data, + errors=errors, + is_url=is_url, + is_upload=is_upload, + is_upload_enabled=is_upload_enabled, + url_label=url_label, + upload_label=upload_label, + placeholder=placeholder %} +{% else %} + {% if is_upload_enabled %} +
    + {% endif %} + + + {{ input(field_url, label=url_label, id='field-image-url', type='url', placeholder=placeholder, value=data.get(field_url), error=errors.get(field_url), classes=['control-full']) }} + + + {% if is_upload_enabled %} + {{ input(field_upload, label=upload_label, id='field-image-upload', type='file', placeholder='', value='', error='', classes=['control-full']) }} + {% if is_upload %} + {{ checkbox(field_clear, label=_('Clear Upload'), id='field-clear-upload', value='true', error='', classes=['control-full']) }} + {% endif %} + {% endif %} + + {% if is_upload_enabled %}
    {% endif %} +{% endif %} + +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/info.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/info.html new file mode 100644 index 0000000..54ecc84 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/info.html @@ -0,0 +1,24 @@ +{# +Renders an info box with a description. This will usually be used with in a +call block when creating an input element. + +text - The text to include in the box. +inline - If true displays the info box inline with the input. +classes - A list of classes to add to the info box. + +Example + +{% import 'macros/form.html' as form %} +{% call form.input('name') %} +{{ form.info(_('My useful help text')) }} +{% endcall %} + +#} +{% macro info(text='', inline=false, classes=[]) %} +{%- if text -%} +
    + +{{ text }} +
    +{%- endif -%} +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input.html new file mode 100644 index 0000000..d7f7c29 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input.html @@ -0,0 +1,30 @@ +{% from "macros/form/input_block.html" import input_block %} +{% from "macros/form/attributes.html" import attributes %} + +{# +Creates all the markup required for an input element. Handles matching labels to +inputs, error messages and other useful elements. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +value - The value of the input. +placeholder - Some placeholder text. +type - The type of input eg. email, url, date (default: text). +error - A list of error strings for the field or just true to highlight the field. +classes - An array of classes to apply to the form-group. +is_required - Boolean of whether this input is requred for the form to validate + +Examples: + +{% import 'macros/form.html' as form %} +{{ form.input('title', label=_('Title'), value=data.title, error=errors.title) }} + +#} +{% macro input(name, id='', label='', value='', placeholder='', type='text', error="", classes=[], attrs={'class': 'form-control'}, is_required=false) %} +{%- set extra_html = caller() if caller -%} + +{% call input_block(id or name, label or name, error, classes, extra_html=extra_html, is_required=is_required) %} + +{% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input_block.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input_block.html new file mode 100644 index 0000000..6f7e691 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/input_block.html @@ -0,0 +1,32 @@ +{# +A generic input_block for providing the default markup for CKAN form elements. +It is expected to be called using a {% call %} block, the contents of which +will be inserted into the .controls element. + +for - The id for the input that the label should match. +label - A human readable label. +error - A list of error strings for the field or just true. +classes - An array of custom classes for the outer element. +control_classes - An array of custom classes for the .control wrapper. +extra_html - An html string to be inserted after the errors eg. info text. +is_required - Boolean of whether this input is requred for the form to validate + +Example: + +{% import 'macros/form.html' as form %} +{% call form.input_block("field", "My Field") %} + +{% endcall %} + +#} + +{% macro input_block(for, label="", error="", classes=[], control_classes=[], extra_html="", is_required=false) %} +
    + +
    +{{ caller() }} +{% if error and error is iterable %}{{ error|join(', ') }}{% endif %} +{{ extra_html }} +
    +
    +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/markdown.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/markdown.html new file mode 100644 index 0000000..59b4a1d --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/markdown.html @@ -0,0 +1,33 @@ +{% from "macros/form/input_block.html" import input_block %} +{% from "macros/form/attributes.html" import attributes %} + +{# +Creates all the markup required for a Markdown textarea element. Handles +matching labels to inputs, selected item and error messages. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +value - The value of the input. +placeholder - Some placeholder text. +error - A list of error strings for the field or just true to highlight the field. +classes - An array of classes to apply to the form-group. +is_required - Boolean of whether this input is requred for the form to validate + +Examples: + +{% import 'macros/form.html' as form %} +{{ form.markdown('desc', id='field-description', label=_('Description'), value=data.desc, error=errors.desc) }} + +#} +{% macro markdown(name, id='', label='', value='', placeholder='', error="", classes=[], attrs={'class': 'form-control'}, is_required=false) %} +{% set classes = (classes|list) %} +{% do classes.append('control-full') %} +{% set markdown_tooltip = "

    __Bold text__ or _italic text_

    # title
    ## secondary title
    ### etc

    * list
    * of
    * items

    http://auto.link.ed/

    Full markdown syntax

    Please note: HTML tags are stripped out for security reasons

    " %} + +{%- set extra_html = caller() if caller -%} +{% call input_block(id or name, label or name, error, classes, control_classes=["editor"], extra_html=extra_html, is_required=is_required) %} + +{% trans %}You can use Markdown formatting here{% endtrans %} +{% endcall %} +{% endmacro %} diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/prepend.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/prepend.html new file mode 100644 index 0000000..1441343 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/prepend.html @@ -0,0 +1,38 @@ +{% from "macros/form/input_block.html" import input_block %} +{% from "macros/form/attributes.html" import attributes %} + +{# +Creates all the markup required for an input element with a prefixed segment. +These are useful for showing url slugs and other fields where the input +information forms only part of the saved data. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +prepend - The text that will be prepended before the input. +value - The value of the input. +which will use the name key as the value. +placeholder - Some placeholder text. +error - A list of error strings for the field or just true to highlight the field. +classes - An array of classes to apply to the form-group. +is_required - Boolean of whether this input is requred for the form to validate + +Examples: + +{% import 'macros/form.html' as form %} +{{ form.prepend('slug', id='field-slug', prepend='/dataset/', label=_('Slug'), value=data.slug, error=errors.slug) }} + +#} +{% macro prepend(name, id='', label='', prepend='', value='', placeholder='', type='text', error="", classes=[], attrs={'class': 'form-control'}, is_required=false) %} +{# We manually append the error here as it needs to be inside the .input-group block #} +{% set classes = (classes|list) %} +{% do classes.append('error') if error %} +{%- set extra_html = caller() if caller -%} +{% call input_block(id or name, label or name, error='', classes=classes, extra_html=extra_html, is_required=is_required) %} +
    + {% if prepend %}{%- endif -%} + + {% if error and error is iterable %}{{ error|join(', ') }}{% endif %} +
    +{% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/required_message.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/required_message.html new file mode 100644 index 0000000..f5a4cc7 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/required_message.html @@ -0,0 +1,13 @@ +{# +Outputs the "* Required field" message for the bottom of formss + +Example +{% import 'macros/form.html' as form %} +{{ form.required_message() }} + +#} +{% macro required_message() %} +

    + * {{ _("Required field") }} +

    +{% endmacro %} \ No newline at end of file diff --git a/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/select.html b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/select.html new file mode 100644 index 0000000..15a1025 --- /dev/null +++ b/src/ckanext-d4science_theme/ckanext/d4science_theme/templates/macros/form/select.html @@ -0,0 +1,50 @@ +{% from "macros/form/input_block.html" import input_block %} +{% from "macros/form/attributes.html" import attributes %} + +{# +Creates all the markup required for an select element. Handles matching labels to +inputs and error messages. + +A field should be a dict with a "value" key and an optional "text" key which +will be displayed to the user. We use a dict to easily allow extension in +future should extra options be required. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +options - A list/tuple of fields to be used as . + selected - The value of the selected