importo template da ckan 2.10

This commit is contained in:
Elia Bellavista 2024-05-21 15:39:28 +02:00
parent fe475f6ce5
commit 94c75b28bf
639 changed files with 70343 additions and 1695 deletions

2
.env
View File

@ -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

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ _service-provider/*
_solr/schema.xml
_src/*
local/*
.vscode/settings.json

View File

@ -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,7 +49,6 @@ 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),
]
for rule in rules:

View File

@ -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;
},
};
});

View File

@ -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 */

View File

@ -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"
}

View File

@ -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;
}

View File

@ -0,0 +1,95 @@
/* User Dashboard
* Handles the filter dropdown menu and the reduction of the notifications number
* within the header to zero
*
* Examples
*
* <div data-module="dashboard"></div>
*
*/
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: '<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body followee-container"></div></div>',
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();
}
}
};
});

View File

@ -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('<div id="fixture">').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');
})
})
})

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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<days>\d+)\s+day(s)?"
patterns.append(days_only_pattern)
hms_only_pattern = r"(?P<hours>\d?\d):(?P<minutes>\d\d):(?P<seconds>\d\d)"
patterns.append(hms_only_pattern)
ms_only_pattern = r".(?P<milliseconds>\d\d\d)(?P<microseconds>\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)

View File

@ -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(
'<option value="{{activity_id}}" {{selected}}>{{timestamp}}</option>',
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")

View File

@ -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,
}

View File

@ -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)

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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)

View File

@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

View File

@ -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)

View File

@ -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 %}

View File

@ -0,0 +1,4 @@
{# Snippet for unit testing dashboard.js #}
<div>
{% snippet 'user/snippets/followee_dropdown.html', context={}, followees=[{"dict": {"id": 1}, "display_name": "Test followee" }, {"dict": {"id": 2}, "display_name": "Not valid" }] %}
</div>

View File

@ -0,0 +1,6 @@
{% ckan_extends %}
{% block styles %}
{{ super() }}
{% asset 'ckanext-activity/activity-css' %}
{% endblock %}

View File

@ -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 %}

View File

@ -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() }}
<li>{% link_for _('Changes'), named_route='activity.group_activity', id=group_dict.name %}</li>
<li class="active">{% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.group_changes', id=activity_diffs[0].activities[1].id %}</li>
{% endblock %}
{% block primary %}
<article class="module">
<div class="module-content">
{% block group_changes_header %}
<h1 class="page-heading">{{ _('Changes') }}</h1>
{% 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) %}
<form id="range_form" action="{{ h.url_for('activity.group_changes_multiple') }}" data-module="select-switch" data-module-target="">
<input type="hidden" name="current_old_id" value="{{ activity_diffs[-1].activities[0].id }}">
<input type="hidden" name="current_new_id" value="{{ activity_diffs[0].activities[1].id }}">
View changes from
<select class="form-control select-time" form="range_form" name="old_id">
<pre>
{{ select_list1[1:]|join }}
</pre>
</select> to
<select class="form-control select-time" form="range_form" name="new_id">
<pre>
{{ select_list2|join }}
</pre>
</select>
</form>
<br>
{# iterate through the list of activity diffs #}
<hr>
{% 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 #}
<input type="button" data-module="metadata-button" data-module-target="" class="btn" value="Show metadata diff" id="metadata_button"></input>
<div id="metadata_diff" style="display:none;">
{% block group_changes_diff %}
<pre>
{{ activity_diffs[0]['diff']|safe }}
</pre>
{% endblock %}
</div>
{% endif %}
<hr>
{% endfor %}
</div>
</article>
{% endblock %}
{% block secondary %}{% endblock %}

View File

@ -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 %}

View File

@ -0,0 +1,10 @@
<strong>{{ 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)) }}</strong>
{% set changes = h.compare_group_dicts(activity_diff.activities[0].data.group, activity_diff.activities[1].data.group, activity_diff.activities[0].id) %}
<ul>
{% for change in changes %}
{% snippet "snippets/group_changes/{}.html".format(
change.type), change=change %}
<br>
{% endfor %}
</ul>

View File

@ -0,0 +1,15 @@
{% ckan_extends %}
{% block header_dashboard %}
{% set new_activities = h.new_activities() %}
<li class="notifications {% if new_activities > 0 %}notifications-important{% endif %}">
{% set notifications_tooltip = ngettext('Dashboard (%(num)d new item)', 'Dashboard (%(num)d new items)',
new_activities)
%}
<a href="{{ h.url_for('activity.dashboard') }}" title="{{ notifications_tooltip }}">
<i class="fa fa-tachometer" aria-hidden="true"></i>
<span class="text">{{ _('Dashboard') }}</span>
<span class="badge">{{ new_activities }}</span>
</a>
</li>
{% endblock %}

View File

@ -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 %}

View File

@ -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() }}
<li>{% link_for _('Changes'), named_route='activity.organization_activity', id=group_dict.name %}</li>
<li class="active">{% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.organization_changes', id=activity_diffs[0].activities[1].id %}</li>
{% endblock %}
{% block primary %}
<article class="module">
<div class="module-content">
{% block organization_changes_header %}
<h1 class="page-heading">{{ _('Changes') }}</h1>
{% 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) %}
<form id="range_form" action="{{ h.url_for('activity.organization_changes_multiple') }}" data-module="select-switch" data-module-target="">
<input type="hidden" name="current_old_id" value="{{ activity_diffs[-1].activities[0].id }}">
<input type="hidden" name="current_new_id" value="{{ activity_diffs[0].activities[1].id }}">
View changes from
<select class="form-control select-time" form="range_form" name="old_id">
<pre>
{{ select_list1[1:]|join }}
</pre>
</select> to
<select class="form-control select-time" form="range_form" name="new_id">
<pre>
{{ select_list2|join }}
</pre>
</select>
</form>
<br>
{# iterate through the list of activity diffs #}
<hr>
{% 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 #}
<input type="button" data-module="metadata-button" data-module-target="" class="btn" value="Show metadata diff" id="metadata_button"></input>
<div id="metadata_diff" style="display:none;">
{% block organization_changes_diff %}
<pre>
{{ activity_diffs[0]['diff']|safe }}
</pre>
{% endblock %}
</div>
{% endif %}
<hr>
{% endfor %}
</div>
</article>
{% endblock %}
{% block secondary %}{% endblock %}

View File

@ -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 %}

View File

@ -0,0 +1,10 @@
<strong>{{ 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)) }}</strong>
{% set changes = h.compare_group_dicts(activity_diff.activities[0].data.group, activity_diff.activities[1].data.group, activity_diff.activities[0].id) %}
<ul>
{% for change in changes %}
{% snippet "snippets/organization_changes/{}.html".format(
change.type), change=change %}
<br>
{% endfor %}
</ul>

View File

@ -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 %}
<p>
{% if activity_type %}
{{ _('No activity found for this type') }}
{% else %}
{{ _('No activity found') }}.
{% endif %}
</p>
{% endif %}
{% snippet 'snippets/pagination.html', newer_activities_url=newer_activities_url, older_activities_url=older_activities_url %}
{% endblock %}

View File

@ -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() }}
<li>{% link_for _('Changes'), named_route='activity.package_activity', id=pkg_dict.name %}</li>
<li class="active">{% link_for activity_diffs[0].activities[1].id|truncate(30), named_route='activity.package_changes', id=activity_diffs[0].activities[1].id %}</li>
{% endblock %}
{% block primary %}
<article class="module">
<div class="module-content">
{% block package_changes_header %}
<h1 class="page-heading">{{ _('Changes') }}</h1>
{% 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) %}
<form id="range_form" action="{{ h.url_for('activity.package_changes_multiple') }}" data-module="select-switch" data-module-target="">
<input type="hidden" name="current_old_id" value="{{ activity_diffs[-1].activities[0].id }}">
<input type="hidden" name="current_new_id" value="{{ activity_diffs[0].activities[1].id }}">
View changes from
<select class="form-control select-time" form="range_form" name="old_id">
<pre>
{{ select_list1[1:]|join }}
</pre>
</select> to
<select class="form-control select-time" form="range_form" name="new_id">
<pre>
{{ select_list2|join }}
</pre>
</select>
</form>
<br>
{# iterate through the list of activity diffs #}
<hr>
{% 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 #}
<input type="button" data-module="metadata-button" data-module-target="" class="btn" value="Show metadata diff" id="metadata_button"></input>
<div id="metadata_diff" style="display:none;">
{% block package_changes_diff %}
<pre>
{{ activity_diffs[0]['diff']|safe }}
</pre>
{% endblock %}
</div>
{% endif %}
<hr>
{% endfor %}
</div>
</article>
{% endblock %}
{% block secondary %}{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "package/read.html" %}
{% block package_description %}
{% block package_archive_notice %}
<div class="alert alert-danger">
{% 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.
<a href="{{ url }}">View the current version</a>.
{% endtrans %}
</div>
{% 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 %}

View File

@ -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 %}

View File

@ -0,0 +1,26 @@
{% extends "package/resource_read.html" %}
{% block action_manage %}
{% endblock action_manage %}
{% block resource_content %}
{% block package_archive_notice %}
<div id="activity-archive-notice" class="alert alert-danger">
{% 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.
<a href="{{ url }}">View the current version</a>.
{% endtrans %}
</div>
{% 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 %}

View File

@ -0,0 +1,10 @@
<strong>{{ 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)) }}</strong>
{% set changes = h.compare_pkg_dicts(activity_diff.activities[0].data.package, activity_diff.activities[1].data.package, activity_diff.activities[0].id) %}
<ul>
{% for change in changes %}
{% snippet "snippets/changes/{}.html".format(
change.type), change=change, pkg_dict=pkg_dict %}
<br>
{% endfor %}
</ul>

View File

@ -0,0 +1,16 @@
{% ckan_extends %}
{% block explore_view %}
{% if is_activity_archive %}
<li>
<a class="dropdown-item" href="{{ url }}">
<i class="fa fa-info-circle"></i>
{{ _('More information') }}
</a>
</li>
{% else %}
{{ super() }}
{% endif %}
{% endblock explore_view %}

View File

@ -0,0 +1,12 @@
{% ckan_extends %}
{% block resources_list %}
<ul class="list-unstyled nav nav-simple">
{% for resource in resources %}
<li class="nav-item{{ ' active' if active == resource.id }}">
<a href="{{ h.url_for("activity.resource_history" if is_activity_archive else '%s_resource.%s' % (pkg.type, (action or 'read')), id=pkg.id if is_activity_archive else pkg.name, resource_id=resource.id, inner_span=true, **({'activity_id': request.view_args.activity_id} if is_activity_archive else {})) }}" title="{{ h.resource_display_name(resource) }}">{{ h.resource_display_name(resource)|truncate(25) }}</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -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 %}
<p class="empty">{{ _('This dataset has no data') }}</p>
{% endblock %}

View File

@ -0,0 +1,6 @@
{% ckan_extends %}
{% block scripts %}
{{ super() }}
{% asset "ckanext-activity/activity" %}
{% endblock scripts %}

View File

@ -0,0 +1,15 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-tag fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} added the tag {tag} to the dataset {dataset}').format(
actor=ah.actor(activity),
dataset=ah.dataset(activity),
tag=ah.tag(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,20 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} updated the group {group}').format(
actor=ah.actor(activity),
group=ah.group(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
{% if can_show_activity_detail %}
&nbsp;|&nbsp;
<a href="{{ h.url_for('activity.group_changes', id=activity.id) }}">
{{ _('Changes') }}
</a>
{% endif %}
</span>
</li>

View File

@ -0,0 +1,20 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-briefcase fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} updated the organization {organization}').format(
actor=ah.actor(activity),
organization=ah.organization(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
{% if can_show_activity_detail %}
&nbsp;|&nbsp;
<a href="{{ h.url_for('activity.organization_changes', id=activity.id) }}">
{{ _('Changes') }}
</a>
{% endif %}
</span>
</li>

View File

@ -0,0 +1,26 @@
{% set dataset_type = activity.data.package.type or 'dataset' %}
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-sitemap fa-stack-1x fa-inverse"></i>
</span>
{{ _('{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 }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
{% if can_show_activity_detail %}
&nbsp;|&nbsp;
<a href="{{ h.url_for('activity.package_history', id=activity.object_id, activity_id=activity.id) }}">
{{ _('View this version') }}
</a>
&nbsp;|&nbsp;
<a href="{{ h.url_for('activity.package_changes', id=activity.id) }}">
{{ _('Changes') }}
</a>
{% endif %}
</span>
</li>

View File

@ -0,0 +1,15 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-icon fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} updated the resource {resource} in the dataset {dataset}').format(
actor=ah.actor(activity),
resource=ah.resource(activity),
dataset=ah.datset(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,13 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} updated their profile').format(
actor=ah.actor(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} deleted the group {group}').format(
actor=ah.actor(activity),
group=ah.group(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-briefcase fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} deleted the organization {organization}').format(
actor=ah.actor(activity),
organization=ah.organization(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,17 @@
{% set dataset_type = activity.data.package.type or 'dataset' %}
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-sitemap fa-stack-1x fa-inverse"></i>
</span>
{{ _('{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 }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,15 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-file fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} deleted the resource {resource} from the dataset {dataset}').format(
actor=ah.actor(activity),
resource=ah.resource(activity),
dataset=ah.dataset(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -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
#}
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{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 %}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-sitemap fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} started following {dataset}').format(
actor=ah.actor(activity),
dataset=ah.dataset(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} started following {group}').format(
actor=ah.actor(activity),
group=ah.group(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} started following {user}').format(
actor=ah.actor(activity),
user=ah.user(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} created the group {group}').format(
actor=ah.actor(activity),
group=ah.group(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,14 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-briefcase fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} created the organization {organization}').format(
actor=ah.actor(activity),
organization=ah.organization(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,22 @@
{% set dataset_type = activity.data.package.type or 'dataset' %}
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-sitemap fa-stack-1x fa-inverse"></i>
</span>
{{ _('{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 }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
{% if can_show_activity_detail %}
&nbsp;|&nbsp;
<a href="{{ h.url_for('activity.package_history', id=activity.object_id, activity_id=activity.id) }}">
{{ _('View this version') }}
</a>
{% endif %}
</span>
</li>

View File

@ -0,0 +1,15 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-file fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} added the resource {resource} to the dataset {dataset}').format(
actor=ah.actor(activity),
resource=ah.resource(activity),
dataset=ah.dataset(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,13 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-user fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} signed up').format(
actor=ah.actor(activity)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -0,0 +1,15 @@
<li class="item {{ activity.activity_type|replace(' ', '-')|lower }}">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x icon"></i>
<i class="fa fa-tag fa-stack-1x fa-inverse"></i>
</span>
{{ _('{actor} removed the tag {tag} from the dataset {dataset}').format(
actor=ah.actor(activity),
tag=ah.tag(activity),
dataset=ah.dataset(dataset)
)|safe }}
<br />
<span class="date" title="{{ h.render_datetime(activity.timestamp, with_hours=True) }}">
{{ h.time_ago_from_timestamp(activity.timestamp) }}
</span>
</li>

View File

@ -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)
#}
<div id="activity_types_filter" style="margin-bottom: 15px;" data-module="activity-stream">
<label for="activity_types_filter_select">Activity type</label>
<select id="activity_types_filter_select">
<option {% if not activity_types %}selected{% endif %} data-url="{{ h.url_for(blueprint, id=id) }}">
{{ _('All activity types') }}
</option>
{% for type_ in activity_types %}
<option {% if activity_type == type_ %}selected{% endif %} data-url="{{ h.url_for(blueprint, id=id, activity_type=type_) }}">
{# TODO: Calling humanize gets complex when displaying orgs/groups since the activities also contains dataset activities #}
{{ type_ }}
</option>
{% endfor %}
</select>
</div>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% 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 %}
</p>
</li>

View File

@ -0,0 +1,47 @@
<li>
<p>
{% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id))%}
{% if change.method == "change" %}
{{ _('Set author email of ')}}
<a href="{{pkg_link}}">
{{ change.title }}
</a>
{{ _('to') }}
<a href="{{ 'mailto:' + change.new_author_email }}">
{{change.new_author_email}}
</a>
{{_('( previously ')}}
<a href="{{ 'mailto:' + change.old_author_email }}">
{{change.old_author_email}}
</a>
{{ _(')') }}
{% elif change.method == "add" %}
{{_('Set author email of')}}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{ 'mailto:' + change.new_author_email }}">
{{ change.new_author_email }}
</a>
{% elif change.method == "remove" %}
{{ _('Removed author email from')}}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,10 @@
<li>
<p>
{{ _('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),
) }}
</p>
</li>

View File

@ -0,0 +1,9 @@
<li>
<p>
{{ _('Changed value of field <q>{key}</q> to <q>{value}</q> 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
)}}
</p>
</li>

View File

@ -0,0 +1,85 @@
<li>
<p>
{% if change.method == "add_one_value" %}
{{ _('Added field <q>{key}</q> with value <q>{value}</q> 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 <q>{key}</q> 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)
)}}
<ul>
{% for item in change.key_list %}
<li>
{% 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 %}
</li>
{% endfor %}
</ul>
{% elif change.method == "change_with_old_value" %}
{{ _('Changed value of field <q>{key}</q> to <q>{new_val}</q> (previously <q>{old_val}</q>) 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 <q>{key}</q> to <q>{new_val}</q> 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 <q>{key}</q> 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)
) }}
<ul>
{% for item in change.key_list %}
<li>
{{ _('<q>{key}</q>').format(
key = item
)| safe }}
</li>
{% endfor %}
</ul>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,67 @@
<li>
<p>
{% 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 ') }}
<a href="{{pkg_link}}">
{{ change.title }}
<a>
{{ _('to') }}
<a href="{{change.new_url}}">
{{change.new_title}}
</a>
{{ _('( previously') }}
<a href="{{change.old_url}}">
{{change.old_title}}
</a>
{{_(')')}}
{# if only the new one has a URL #}
{% elif change.new_url != "" and change.old_url == "" %}
{{ _('Changed the license of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{change.new_url}}">
{{change.new_title}}
</a>
{{ _('(previously') + change.old_title + ' )'}}
{# if only the old one has a URL #}
{% elif change.new_url == "" and change.old_url != "" %}
{{ _('Changed the license of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') + change.new_title }}
{{ _('( previously') }}
<a href="{{change.old_url}}">
{{change.old_title}}
</a>
{{_(')')}}
{# otherwise neither has a URL #}
{% else %}
{{ _('Changed the license of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') + change.new_title}}
{{ _('(previously') + change.old_title + _(')')|safe }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% 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 %}
</p>
</li>

View File

@ -0,0 +1,47 @@
<li>
<p>
{% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id)) %}
{% if change.method == "change" %}
{{ _('Set maintainer email of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{'mailto:' + change.new_maintainer_email}}">
{{change.new_maintainer_email}}
</a>
{{ _('(previously') }}
<a href="{{'mailto:' + change.old_maintainer_email}}">
{{change.old_maintainer_email}}
</a>
{{ _(')') }}
{% elif change.method == "add" %}
{{ _('Set maintainer email of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{'mailto:' + change.new_maintainer_email}}">
{{change.new_maintainer_email}}
</a>
{% elif change.method == "remove" %}
{{ _('Removed maintainer email from') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,23 @@
<li>
<p>
{% 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')}}
<a href="{{ h.url_for('dataset.read', id=change.pkg_id) }}">
{{change.title}}
</a>
{{ _('from') }}
<a href="{{old_url}}">
{{old_url}}
</a>
{{ _('to') }}
<a href="{{new_url}}">
{{new_url}}
</a>
</p>
</li>

View File

@ -0,0 +1,10 @@
<li>
<p>
{{ _('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)
) }}
</p>
</li>

View File

@ -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 %}
<a href="{{ pkg_url }}">{{ change.title }}</a>
{% endset %}
{% set resource_link %}
<a href="{{ resource_url }}">{{ change.resource_name or _('Unnamed resource') }}</a>
{% endset %}
<li>
<p>
{{ _('Added resource {resource_link} to {pkg_link}').format(pkg_link=pkg_link, resource_link=resource_link) }}
</p>
</li>

View File

@ -0,0 +1,5 @@
<li>
<p>
{{ _('No fields were updated. See the metadata diff for more details.') }}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% if change.method == "change" %}
{{ _('Updated description of {pkg_link} from <br class="line-height2"><blockquote>{old_notes}</blockquote> to <br class="line-height2"><blockquote>{new_notes}</blockquote>').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 <br class="line-height2"><blockquote>{new_notes}</blockquote>').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 %}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% 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 %}
</p>
</li>

View File

@ -0,0 +1,8 @@
<li>
<p>
{{ _('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
) }}
</p>
</li>

View File

@ -0,0 +1,39 @@
<li>
<p>
{% if change.method == "add" %}
{{ _('Updated description of resource {resource_link} in {pkg_link} to <br class="line-height2"><blockquote>{new_desc}</blockquote>').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 <br class="line-height2"><blockquote>{old_desc}</blockquote> to <br class="line-height2"><blockquote>{new_desc}</blockquote>').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 %}
</p>
</li>

View File

@ -0,0 +1,112 @@
<li>
<p>
{% if change.method == "add_one_value" %}
{{ _('Added field <q>{key}</q> with value <q>{value}</q> 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 <q>{key}</q> 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)
) }}
<ul>
{% 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 %}
</ul>
{% elif change.method == "remove_one" %}
{{ _('Removed field <q>{key}</q> 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)
) }}
<ul>
{% for item in change.key_list %}
{{ _('{key}').format(
key = item
)|safe }}
{% endfor %}
</ul>
{% elif change.method == "change_value_with_old" %}
{{ _('Changed value of field <q>{key}</q> of resource {resource_link} to <q>{new_val}</q> (previously <q>{old_val}</q>) 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 <q>{key}</q> to <q>{new_val}</q> 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 <q>{key}</q> 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 %}
</p>
</li>

View File

@ -0,0 +1,54 @@
<li>
<p>
{% 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') }}
<a href="{{resource_url}}">
{{change.resource_name}}
</a>
{{ _('to') }}
<a href="{{format_search_base_url + '?res_format=' + change.format}}">
{{change.format}}
</a>
{{ _('in') }}
<a href="{{ h.url_for('dataset.read', id=change.pkg_id) }}">
{{change.title}}
</a>
{% elif change.method == "change" %}
{{ _('Set format of resource') }}
<a href="{{resource_url}}">
{{change.resource_name}}
</a>
{{ _('to') }}
<a href="{{format_search_base_url + '?res_format=' + change.new_format}}">
{{change.new_format}}
</a>
{{ _('(previously') }}
<a href="{{format_search_base_url + '?res_format=' + change.old_format}}">
{{change.old_format}}
</a>
{{ _(')') }}
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,13 @@
<li>
<p>
{{ _('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),
) }}
</p>
</li>

View File

@ -0,0 +1,59 @@
<li>
<p>
{% if change.method == "remove_one" %}
{{ _('Removed tag {tag_link} from {pkg_link}').format(
tag_link = '<a href="{tag_url}">{tag}</a>'.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 }}
<ul>
{% for item in change.tags %}
<li>
{{ _('{tag_link}').format(
tag_link = h.nav_link(item, named_route='dataset.search', tags=item),
)}}
</li>
{% endfor %}
</ul>
{% 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),
) }}
<ul>
{% for item in change.tags %}
<li>
{# TODO: figure out which controller to actually use here #}
{{ _('{tag_link}').format(
tag_link = h.nav_link(item, named_route='dataset.search', tags=item),
)}}
</li>
{% endfor %}
</ul>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,8 @@
<li>
<p>
{{ _('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
) }}
</p>
</li>

View File

@ -0,0 +1,46 @@
<li>
<p>
{% set pkg_link = (h.url_for('dataset.read', id=change.pkg_id)) %}
{% if change.method == "change" %}
{{ _('Changed the source URL of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('from') }}
<a href="{{'http://' + change.old_url if not change.old_url[0:4] == 'http' else change.old_url}}">
{{change.old_url}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_url if not change.new_url[0:4] == 'http' else change.new_url}}">
{{change.new_url}}
</a>
{% elif change.method == "remove" %}
{{ _('Removed the source URL from') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{% elif change.method == "add" %}
{{_('Changed the source URL of') }}
<a href="{{pkg_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_url if not change.new_url[0:4] == 'http' else change.new_url}}">
{{change.new_url}}
</a>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% 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 %}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% if change.method == "change" %}
{{ _('Updated description of {group_link} from <br class="line-height2"><blockquote>{old_description}</blockquote> to <br class="line-height2"><blockquote>{new_description}</blockquote>').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 <br class="line-height2"><blockquote>{new_description}</blockquote>').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 %}
</p>
</li>

View File

@ -0,0 +1,46 @@
<li>
<p>
{% set group_link = (h.url_for('group.read', id=change.pkg_id)) %}
{% if change.method == "change" %}
{{ _('Changed the image URL of') }}
<a href="{{group_link}}">
{{change.title}}
</a>
{{ _('from') }}
<a href="{{'http://' + change.old_image_url if not change.old_image_url[0:4] == 'http' else change.old_image_url}}">
{{change.old_image_url}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_image_url if not change.new_image_url[0:4] == 'http' else change.new_image_url}}">
{{change.new_image_url}}
</a>
{% elif change.method == "remove" %}
{{ _('Removed the image URL from') }}
<a href="{{group_link}}">
{{change.title}}
</a>
{% elif change.method == "add" %}
{{ _('Changed the image URL of') }}
<a href="{{group_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_image_url if not change.new_image_url[0:4] == 'http' else change.new_image_url}}">
{{change.new_image_url}}
</a>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,5 @@
<li>
<p>
{{ _('No fields were updated. See the metadata diff for more details.') }}
</p>
</li>

View File

@ -0,0 +1,8 @@
<li>
<p>
{{ _('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
) }}
</p>
</li>

View File

@ -0,0 +1,30 @@
<li>
<p>
{% if change.method == "change" %}
{{ _('Updated description of {org_link} from <br class="line-height2"><blockquote>{old_description}</blockquote> to <br class="line-height2"><blockquote>{new_description}</blockquote>').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 <br class="line-height2"><blockquote>{new_description}</blockquote>').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 %}
</p>
</li>

View File

@ -0,0 +1,46 @@
<li>
<p>
{% set org_link = (h.url_for('organization.read', id=change.pkg_id)) %}
{% if change.method == "change" %}
{{ _('Changed the image URL of') }}
<a href="{{org_link}}">
{{change.title}}
</a>
{{ _('from') }}
<a href="{{'http://' + change.old_image_url if not change.old_image_url[0:4] == 'http' else change.old_image_url}}">
{{change.old_image_url}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_image_url if not change.new_image_url[0:4] == 'http' else change.new_image_url}}">
{{change.new_image_url}}
</a>
{% elif change.method == "remove" %}
{{ _('Removed the image URL from') }}
<a href="{{org_link}}">
{{change.title}}
</a>
{% elif change.method == "add" %}
{{ _('Changed the image URL of') }}
<a href="{{org_link}}">
{{change.title}}
</a>
{{ _('to') }}
<a href="{{'http://' + change.new_image_url if not change.new_image_url[0:4] == 'http' else change.new_image_url}}">
{{change.new_image_url}}
</a>
{% else %}
{{ _('No fields were updated. See the metadata diff for more details.') }}
{% endif %}
</p>
</li>

View File

@ -0,0 +1,5 @@
<li>
<p>
{{ _('No fields were updated. See the metadata diff for more details.') }}
</p>
</li>

View File

@ -0,0 +1,8 @@
<li>
<p>
{{ _('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
) }}
</p>
</li>

View File

@ -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" %}
<div id="activity_page_buttons" class="activity_buttons" style="margin-top: 25px;">
<a href="{{ newer_activities_url }}" class="{{ class_prev }}">{{ _('Newer activities') }}</a>
<a href="{{ older_activities_url }}" class="{{ class_next }}">{{ _('Older activities') }}</a>
</div>

Some files were not shown because too many files have changed in this diff Show More