feat: Changed to ubuntu docker images
This commit is contained in:
parent
674b127728
commit
0b07379bb3
|
@ -9,13 +9,13 @@ MAINTENANCE_MODE=false
|
|||
CKAN_SITE_ID=default
|
||||
CKAN_SITE_URL=http://localhost:5000
|
||||
CKAN_PORT=5000
|
||||
CKAN_MAX_UPLOAD_SIZE_MB=20
|
||||
CKAN_MAX_UPLOAD_SIZE_MB=200
|
||||
CKAN___BEAKER__SESSION__SECRET=CHANGE_ME
|
||||
# See https://docs.ckan.org/en/latest/maintaining/configuration.html#api-token-settings
|
||||
CKAN___API_TOKEN__JWT__ENCODE__SECRET=string:CHANGE_ME
|
||||
CKAN___API_TOKEN__JWT__DECODE__SECRET=string:CHANGE_ME
|
||||
# CKAN Plugins
|
||||
CKAN__PLUGINS=envvars image_view text_view recline_view datastore datapusher
|
||||
CKAN__PLUGINS="envvars image_view text_view recline_view datastore datapusher spatial_metadata spatial_query d4science d4science_theme"
|
||||
# CKAN requires storage path to be set in order for filestore to be enabled
|
||||
CKAN__STORAGE_PATH=/srv/app/data
|
||||
CKAN__WEBASSETS__PATH=/srv/app/data/webassets
|
|
@ -0,0 +1,39 @@
|
|||
###################
|
||||
### Extensions
|
||||
###################
|
||||
FROM maicol07/ckan:2.10.4-focal as extbuild
|
||||
|
||||
# Switch to the root user
|
||||
USER root
|
||||
|
||||
#install pip
|
||||
RUN apt-get update
|
||||
RUN apt-get install python3-dev python3-pip libxml2-dev libxslt1-dev libgeos-c1v5 -y
|
||||
|
||||
############
|
||||
### MAIN
|
||||
############
|
||||
FROM maicol07/ckan:2.10.4-focal
|
||||
|
||||
# Add the custom extensions to the plugins list
|
||||
ENV CKAN__PLUGINS envvars image_view text_view recline_view datastore datapusher spatial_metadata spatial_query d4science d4science_theme
|
||||
|
||||
# Switch to the root user
|
||||
USER root
|
||||
|
||||
#COPY --from=extbuild /wheels /srv/app/ext_wheels
|
||||
|
||||
# Add requirements.txt to the image
|
||||
COPY requirements.txt /srv/app/requirements.txt
|
||||
|
||||
# Install and enable the custom extensions
|
||||
RUN pip install -r /srv/app/requirements.txt
|
||||
|
||||
RUN ckan config-tool ${APP_DIR}/production.ini "ckan.plugins = ${CKAN__PLUGINS}" && \
|
||||
chown -R ckan:ckan /srv/app
|
||||
|
||||
# Remove wheels
|
||||
RUN rm -rf /srv/app/ext_wheels
|
||||
|
||||
# Switch to the ckan user
|
||||
#USER ckan
|
|
@ -9,7 +9,8 @@ volumes:
|
|||
services:
|
||||
ckan:
|
||||
container_name: ckan
|
||||
image: ghcr.io/keitaroinc/ckan:${CKAN_VERSION}
|
||||
build:
|
||||
context: .
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
|
@ -19,7 +20,7 @@ services:
|
|||
ports:
|
||||
- "0.0.0.0:${CKAN_PORT}:5000"
|
||||
env_file:
|
||||
- ./.ckan-env
|
||||
- ./.ckan.env
|
||||
environment:
|
||||
- CKAN_SQLALCHEMY_URL=postgresql://ckan:${POSTGRES_PASSWORD}@db/ckan
|
||||
- CKAN_DATASTORE_WRITE_URL=postgresql://ckan:${POSTGRES_PASSWORD}@db/datastore
|
||||
|
@ -33,6 +34,8 @@ services:
|
|||
|
||||
volumes:
|
||||
- ckan_data:/srv/app/data
|
||||
- ./src:/srv/app/src_extensions
|
||||
- ./docker-entrypoint.d:/srv/app/docker-entrypoint.d
|
||||
|
||||
datapusher:
|
||||
container_name: datapusher
|
||||
|
@ -73,11 +76,11 @@ services:
|
|||
|
||||
solr:
|
||||
container_name: solr
|
||||
image: solr:8.11.1
|
||||
image: ckan/ckan-solr:2.10-solr9-spatial
|
||||
networks:
|
||||
- backend
|
||||
env_file:
|
||||
- ./.ckan-env
|
||||
- ./.ckan.env
|
||||
environment:
|
||||
- CKAN_CORE_NAME=${CKAN_CORE_NAME}
|
||||
- CKAN_VERSION=${CKAN_VERSION}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ $CKAN__PLUGINS == *"spatial_metadata"* ]]; then
|
||||
echo "Configuring CKAN spatial ..."
|
||||
|
||||
ckan config-tool ${APP_DIR}/production.ini "ckanext.spatial.common_map.type=Stadia.StamenTerrain"
|
||||
ckan config-tool ${APP_DIR}/production.ini "ckanext.spatial.common_map.max_zoom=18"
|
||||
# ckan config-tool $CKAN_INI "ckanext.spatial.search_backend = solr-spatial-field"
|
||||
# ckan config-tool $CKAN_INI "ckan.search.solr_allowed_query_parsers = field"
|
||||
# forse è da aggiungere un qualche tipo di key per production
|
||||
|
||||
else
|
||||
echo "Not configuring spatial, plugin not enabled."
|
||||
fi
|
|
@ -0,0 +1,42 @@
|
|||
SRC_EXTENSIONS_DIR=/srv/app/src_extensions
|
||||
|
||||
# Install any local extensions in the src_extensions volume
|
||||
echo "Looking for local extensions to install..."
|
||||
echo "Extension dir ($SRC_EXTENSIONS_DIR) contents:"
|
||||
ls -la $SRC_EXTENSIONS_DIR
|
||||
for i in $SRC_EXTENSIONS_DIR/*
|
||||
do
|
||||
if [ -d $i ];
|
||||
then
|
||||
|
||||
if [ -f $i/pip-requirements.txt ];
|
||||
then
|
||||
pip install -r $i/pip-requirements.txt
|
||||
echo "Found requirements file in $i"
|
||||
fi
|
||||
if [ -f $i/requirements.txt ];
|
||||
then
|
||||
pip install -r $i/requirements.txt
|
||||
echo "Found requirements file in $i"
|
||||
fi
|
||||
if [ -f $i/dev-requirements.txt ];
|
||||
then
|
||||
pip install -r $i/dev-requirements.txt
|
||||
echo "Found dev-requirements file in $i"
|
||||
fi
|
||||
if [ -f $i/setup.py ];
|
||||
then
|
||||
cd $i
|
||||
python3 $i/setup.py develop
|
||||
echo "Found setup.py file in $i"
|
||||
cd $APP_DIR
|
||||
fi
|
||||
|
||||
# Point `use` in test.ini to location of `test-core.ini`
|
||||
if [ -f $i/test.ini ];
|
||||
then
|
||||
echo "Updating \`test.ini\` reference to \`test-core.ini\` for plugin $i"
|
||||
ckan config-tool $i/test.ini "use = config:../../src/ckan/test-core.ini"
|
||||
fi
|
||||
fi
|
||||
done
|
|
@ -0,0 +1,3 @@
|
|||
# Spatial
|
||||
-e git+https://github.com/ckan/ckanext-spatial.git@v2.1.1#egg=ckanext-spatial
|
||||
-r https://raw.githubusercontent.com/ckan/ckanext-spatial/v2.1.1/requirements.txt
|
|
@ -0,0 +1,5 @@
|
|||
[report]
|
||||
omit =
|
||||
*/site-packages/*
|
||||
*/python?.?/*
|
||||
ckan/*
|
|
@ -0,0 +1,48 @@
|
|||
name: Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# The CKAN version tag of the Solr and Postgres containers should match
|
||||
# the one of the container the tests run on.
|
||||
# You can switch this base image with a custom image tailored to your project
|
||||
image: ckan/ckan-dev:2.10
|
||||
services:
|
||||
solr:
|
||||
image: ckan/ckan-solr:2.10-solr9
|
||||
postgres:
|
||||
image: ckan/ckan-postgres-dev:2.10
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:3
|
||||
|
||||
env:
|
||||
CKAN_SQLALCHEMY_URL: postgresql://ckan_default:pass@postgres/ckan_test
|
||||
CKAN_DATASTORE_WRITE_URL: postgresql://datastore_write:pass@postgres/datastore_test
|
||||
CKAN_DATASTORE_READ_URL: postgresql://datastore_read:pass@postgres/datastore_test
|
||||
CKAN_SOLR_URL: http://solr:8983/solr/ckan
|
||||
CKAN_REDIS_URL: redis://redis:6379/1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install requirements
|
||||
# Install any extra requirements your extension has here (dev requirements, other extensions etc)
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install -r dev-requirements.txt
|
||||
pip install -e .
|
||||
- name: Setup extension
|
||||
# Extra initialization steps
|
||||
run: |
|
||||
# Replace default path to CKAN core config file with the one on the container
|
||||
sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini
|
||||
|
||||
ckan -c test.ini db init
|
||||
- name: Run tests
|
||||
run: pytest --ckan-ini=test.ini --cov=ckanext.d4science --disable-warnings ckanext/d4science
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
.ropeproject
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
sdist/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,5 @@
|
|||
include README.rst
|
||||
include LICENSE
|
||||
include requirements.txt
|
||||
recursive-include ckanext/d4science *.html *.json *.js *.less *.css *.mo *.yml
|
||||
recursive-include ckanext/d4science/migration *.ini *.py *.mako
|
|
@ -0,0 +1,123 @@
|
|||
[![Tests](https://github.com/d4science/ckanext-d4science/workflows/Tests/badge.svg?branch=main)](https://github.com/d4science/ckanext-d4science/actions)
|
||||
|
||||
# ckanext-d4science
|
||||
|
||||
**TODO:** Put a description of your extension here: What does it do? What features does it have? Consider including some screenshots or embedding a video!
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
**TODO:** For example, you might want to mention here which versions of CKAN this
|
||||
extension works with.
|
||||
|
||||
If your extension works across different versions you can add the following table:
|
||||
|
||||
Compatibility with core CKAN versions:
|
||||
|
||||
| CKAN version | Compatible? |
|
||||
| --------------- | ------------- |
|
||||
| 2.6 and earlier | not tested |
|
||||
| 2.7 | not tested |
|
||||
| 2.8 | not tested |
|
||||
| 2.9 | not tested |
|
||||
|
||||
Suggested values:
|
||||
|
||||
* "yes"
|
||||
* "not tested" - I can't think of a reason why it wouldn't work
|
||||
* "not yet" - there is an intention to get it working
|
||||
* "no"
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
**TODO:** Add any additional install steps to the list below.
|
||||
For example installing any non-Python dependencies or adding any required
|
||||
config settings.
|
||||
|
||||
To install ckanext-d4science:
|
||||
|
||||
1. Activate your CKAN virtual environment, for example:
|
||||
|
||||
. /usr/lib/ckan/default/bin/activate
|
||||
|
||||
2. Clone the source and install it on the virtualenv
|
||||
|
||||
git clone https://github.com/d4science/ckanext-d4science.git
|
||||
cd ckanext-d4science
|
||||
pip install -e .
|
||||
pip install -r requirements.txt
|
||||
|
||||
3. Add `d4science` to the `ckan.plugins` setting in your CKAN
|
||||
config file (by default the config file is located at
|
||||
`/etc/ckan/default/ckan.ini`).
|
||||
|
||||
4. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu:
|
||||
|
||||
sudo service apache2 reload
|
||||
|
||||
|
||||
## Config settings
|
||||
|
||||
None at present
|
||||
|
||||
**TODO:** Document any optional config settings here. For example:
|
||||
|
||||
# The minimum number of hours to wait before re-checking a resource
|
||||
# (optional, default: 24).
|
||||
ckanext.d4science.some_setting = some_default_value
|
||||
|
||||
|
||||
## Developer installation
|
||||
|
||||
To install ckanext-d4science for development, activate your CKAN virtualenv and
|
||||
do:
|
||||
|
||||
git clone https://github.com/d4science/ckanext-d4science.git
|
||||
cd ckanext-d4science
|
||||
python setup.py develop
|
||||
pip install -r dev-requirements.txt
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
To run the tests, do:
|
||||
|
||||
pytest --ckan-ini=test.ini
|
||||
|
||||
|
||||
## Releasing a new version of ckanext-d4science
|
||||
|
||||
If ckanext-d4science should be available on PyPI you can follow these steps to publish a new version:
|
||||
|
||||
1. Update the version number in the `setup.py` file. See [PEP 440](http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers) for how to choose version numbers.
|
||||
|
||||
2. Make sure you have the latest version of necessary packages:
|
||||
|
||||
pip install --upgrade setuptools wheel twine
|
||||
|
||||
3. Create a source and binary distributions of the new version:
|
||||
|
||||
python setup.py sdist bdist_wheel && twine check dist/*
|
||||
|
||||
Fix any errors you get.
|
||||
|
||||
4. Upload the source distribution to PyPI:
|
||||
|
||||
twine upload dist/*
|
||||
|
||||
5. Commit any outstanding changes:
|
||||
|
||||
git commit -a
|
||||
git push
|
||||
|
||||
6. Tag the new release of the project on GitHub with the version number from
|
||||
the `setup.py` file. For example if the version number in `setup.py` is
|
||||
0.0.1 then do:
|
||||
|
||||
git tag 0.0.1
|
||||
git push --tags
|
||||
|
||||
## License
|
||||
|
||||
[AGPL](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
|
@ -0,0 +1,9 @@
|
|||
# encoding: utf-8
|
||||
|
||||
# this is a namespace package
|
||||
try:
|
||||
import pkg_resources
|
||||
pkg_resources.declare_namespace(__name__)
|
||||
except ImportError:
|
||||
import pkgutil
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
|
@ -0,0 +1,10 @@
|
|||
ckan.module("d4science-module", function ($, _) {
|
||||
"use strict";
|
||||
return {
|
||||
options: {
|
||||
debug: false,
|
||||
},
|
||||
|
||||
initialize: function () {},
|
||||
};
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
/*
|
||||
body {
|
||||
border-radius: 0;
|
||||
}
|
||||
*/
|
|
@ -0,0 +1,14 @@
|
|||
# d4science-js:
|
||||
# filter: rjsmin
|
||||
# output: ckanext-d4science/%(version)s-d4science.js
|
||||
# contents:
|
||||
# - js/d4science.js
|
||||
# extra:
|
||||
# preload:
|
||||
# - base/main
|
||||
|
||||
# d4science-css:
|
||||
# filter: cssrewrite
|
||||
# output: ckanext-d4science/%(version)s-d4science.css
|
||||
# contents:
|
||||
# - css/d4science.css
|
|
@ -0,0 +1,20 @@
|
|||
import click
|
||||
|
||||
|
||||
@click.group(short_help="d4science CLI.")
|
||||
def d4science():
|
||||
"""d4science CLI.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@d4science.command()
|
||||
@click.argument("name", default="d4science")
|
||||
def command(name):
|
||||
"""Docs.
|
||||
"""
|
||||
click.echo("Hello, {name}!".format(name=name))
|
||||
|
||||
|
||||
def get_commands():
|
||||
return [d4science]
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
def d4science_hello():
|
||||
return "Hello, d4science!"
|
||||
|
||||
|
||||
def get_helpers():
|
||||
return {
|
||||
"d4science_hello": d4science_hello,
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import ckan.plugins.toolkit as tk
|
||||
import ckanext.d4science.logic.schema as schema
|
||||
|
||||
|
||||
@tk.side_effect_free
|
||||
def d4science_get_sum(context, data_dict):
|
||||
tk.check_access(
|
||||
"d4science_get_sum", context, data_dict)
|
||||
data, errors = tk.navl_validate(
|
||||
data_dict, schema.d4science_get_sum(), context)
|
||||
|
||||
if errors:
|
||||
raise tk.ValidationError(errors)
|
||||
|
||||
return {
|
||||
"left": data["left"],
|
||||
"right": data["right"],
|
||||
"sum": data["left"] + data["right"]
|
||||
}
|
||||
|
||||
|
||||
def get_actions():
|
||||
return {
|
||||
'd4science_get_sum': d4science_get_sum,
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import ckan.plugins.toolkit as tk
|
||||
|
||||
|
||||
@tk.auth_allow_anonymous_access
|
||||
def d4science_get_sum(context, data_dict):
|
||||
return {"success": True}
|
||||
|
||||
|
||||
def get_auth_functions():
|
||||
return {
|
||||
"d4science_get_sum": d4science_get_sum,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import ckan.plugins.toolkit as tk
|
||||
|
||||
|
||||
def d4science_get_sum():
|
||||
not_empty = tk.get_validator("not_empty")
|
||||
convert_int = tk.get_validator("convert_int")
|
||||
|
||||
return {
|
||||
"left": [not_empty, convert_int],
|
||||
"right": [not_empty, convert_int]
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import ckan.plugins.toolkit as tk
|
||||
|
||||
|
||||
def d4science_required(value):
|
||||
if not value or value is tk.missing:
|
||||
raise tk.Invalid(tk._("Required"))
|
||||
return value
|
||||
|
||||
|
||||
def get_validators():
|
||||
return {
|
||||
"d4science_required": d4science_required,
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import ckan.plugins as plugins
|
||||
import ckan.plugins.toolkit as toolkit
|
||||
from ckan.views.resource import Blueprint
|
||||
from ckanext.d4science import helpers
|
||||
from ckanext.d4science.logic import action, auth
|
||||
from ckanext.d4science.logic import validators
|
||||
|
||||
|
||||
# import ckanext.d4science.cli as cli
|
||||
# import ckanext.d4science.helpers as helpers
|
||||
# import ckanext.d4science.views as views
|
||||
# from ckanext.d4science.logic import (
|
||||
# action, auth, validators
|
||||
# )
|
||||
|
||||
|
||||
class D4SciencePlugin(plugins.SingletonPlugin):
|
||||
plugins.implements(plugins.IConfigurer)
|
||||
|
||||
plugins.implements(plugins.IAuthFunctions)
|
||||
plugins.implements(plugins.IActions)
|
||||
# plugins.implements(plugins.IClick)
|
||||
plugins.implements(plugins.ITemplateHelpers)
|
||||
plugins.implements(plugins.IValidators)
|
||||
|
||||
#ckan 2.10
|
||||
plugins.implements(plugins.IBlueprint)
|
||||
|
||||
|
||||
# IConfigurer
|
||||
|
||||
def update_config(self, config_):
|
||||
toolkit.add_template_directory(config_, "templates")
|
||||
toolkit.add_public_directory(config_, "public")
|
||||
toolkit.add_resource("assets", "d4science")
|
||||
|
||||
|
||||
# IAuthFunctions
|
||||
|
||||
def get_auth_functions(self):
|
||||
return auth.get_auth_functions()
|
||||
|
||||
# IActions
|
||||
|
||||
def get_actions(self):
|
||||
return action.get_actions()
|
||||
|
||||
# IBlueprint
|
||||
|
||||
# def get_blueprint(self):
|
||||
# return views.get_blueprints()
|
||||
|
||||
def get_blueprint(self):
|
||||
blueprint = Blueprint('foo', self.__module__)
|
||||
# rules = [
|
||||
# ('/group', 'group_index', custom_group_index),
|
||||
# ]
|
||||
# for rule in rules:
|
||||
# blueprint.add_url_rule(*rule)
|
||||
|
||||
return blueprint
|
||||
|
||||
# IClick
|
||||
|
||||
# def get_commands(self):
|
||||
# return cli.get_commands()
|
||||
|
||||
# ITemplateHelpers
|
||||
|
||||
def get_helpers(self):
|
||||
return helpers.get_helpers()
|
||||
|
||||
# IValidators
|
||||
|
||||
def get_validators(self):
|
||||
return validators.get_validators()
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"""Tests for action.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
import ckan.tests.helpers as test_helpers
|
||||
|
||||
|
||||
@pytest.mark.ckan_config("ckan.plugins", "d4science")
|
||||
@pytest.mark.usefixtures("with_plugins")
|
||||
def test_d4science_get_sum():
|
||||
result = test_helpers.call_action(
|
||||
"d4science_get_sum", left=10, right=30)
|
||||
assert result["sum"] == 40
|
|
@ -0,0 +1,19 @@
|
|||
"""Tests for auth.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
import ckan.tests.factories as factories
|
||||
import ckan.tests.helpers as test_helpers
|
||||
import ckan.model as model
|
||||
|
||||
|
||||
@pytest.mark.ckan_config("ckan.plugins", "d4science")
|
||||
@pytest.mark.usefixtures("with_request_context", "with_plugins", "clean_db")
|
||||
def test_d4science_get_sum():
|
||||
user = factories.User()
|
||||
context = {
|
||||
"user": user["name"],
|
||||
"model": model
|
||||
}
|
||||
assert test_helpers.call_auth(
|
||||
"d4science_get_sum", context=context)
|
|
@ -0,0 +1,16 @@
|
|||
"""Tests for validators.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
import ckan.plugins.toolkit as tk
|
||||
|
||||
from ckanext.d4science.logic import validators
|
||||
|
||||
|
||||
def test_d4science_reauired_with_valid_value():
|
||||
assert validators.d4science_required("value") == "value"
|
||||
|
||||
|
||||
def test_d4science_reauired_with_invalid_value():
|
||||
with pytest.raises(tk.Invalid):
|
||||
validators.d4science_required(None)
|
|
@ -0,0 +1,7 @@
|
|||
"""Tests for helpers.py."""
|
||||
|
||||
import ckanext.d4science.helpers as helpers
|
||||
|
||||
|
||||
def test_d4science_hello():
|
||||
assert helpers.d4science_hello() == "Hello, d4science!"
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
Tests for plugin.py.
|
||||
|
||||
Tests are written using the pytest library (https://docs.pytest.org), and you
|
||||
should read the testing guidelines in the CKAN docs:
|
||||
https://docs.ckan.org/en/2.9/contributing/testing.html
|
||||
|
||||
To write tests for your extension you should install the pytest-ckan package:
|
||||
|
||||
pip install pytest-ckan
|
||||
|
||||
This will allow you to use CKAN specific fixtures on your tests.
|
||||
|
||||
For instance, if your test involves database access you can use `clean_db` to
|
||||
reset the database:
|
||||
|
||||
import pytest
|
||||
|
||||
from ckan.tests import factories
|
||||
|
||||
@pytest.mark.usefixtures("clean_db")
|
||||
def test_some_action():
|
||||
|
||||
dataset = factories.Dataset()
|
||||
|
||||
# ...
|
||||
|
||||
For functional tests that involve requests to the application, you can use the
|
||||
`app` fixture:
|
||||
|
||||
from ckan.plugins import toolkit
|
||||
|
||||
def test_some_endpoint(app):
|
||||
|
||||
url = toolkit.url_for('myblueprint.some_endpoint')
|
||||
|
||||
response = app.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
To temporary patch the CKAN configuration for the duration of a test you can use:
|
||||
|
||||
import pytest
|
||||
|
||||
@pytest.mark.ckan_config("ckanext.myext.some_key", "some_value")
|
||||
def test_some_action():
|
||||
pass
|
||||
"""
|
||||
import ckanext.d4science.plugin as plugin
|
||||
|
||||
|
||||
@pytest.mark.ckan_config("ckan.plugins", "d4science")
|
||||
@pytest.mark.usefixtures("with_plugins")
|
||||
def test_plugin():
|
||||
assert plugin_loaded("d4science")
|
|
@ -0,0 +1,16 @@
|
|||
"""Tests for views.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
import ckanext.d4science.validators as validators
|
||||
|
||||
|
||||
import ckan.plugins.toolkit as tk
|
||||
|
||||
|
||||
@pytest.mark.ckan_config("ckan.plugins", "d4science")
|
||||
@pytest.mark.usefixtures("with_plugins")
|
||||
def test_d4science_blueprint(app, reset_db):
|
||||
resp = app.get(tk.h.url_for("d4science.page"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == "Hello, d4science!"
|
|
@ -0,0 +1,17 @@
|
|||
from flask import Blueprint
|
||||
|
||||
|
||||
d4science = Blueprint(
|
||||
"d4science", __name__)
|
||||
|
||||
|
||||
def page():
|
||||
return "Hello, d4science!"
|
||||
|
||||
|
||||
d4science.add_url_rule(
|
||||
"/d4science/page", view_func=page)
|
||||
|
||||
|
||||
def get_blueprints():
|
||||
return [d4science]
|
|
@ -0,0 +1 @@
|
|||
pytest-ckan
|
|
@ -0,0 +1,62 @@
|
|||
[metadata]
|
||||
name = ckanext-d4science
|
||||
version = 0.0.1
|
||||
description = d4science custom extensions
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://github.com/d4science/ckanext-d4science
|
||||
author = d4science
|
||||
author_email = info@d4science.org
|
||||
license = AGPL
|
||||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
keywords = CKAN
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
namespace_packages = ckanext
|
||||
install_requires =
|
||||
include_package_data = True
|
||||
|
||||
[options.entry_points]
|
||||
ckan.plugins =
|
||||
d4science = ckanext.d4science.plugin:D4SciencePlugin
|
||||
|
||||
babel.extractors =
|
||||
ckan = ckan.lib.extract:extract_ckan
|
||||
|
||||
[options.extras_require]
|
||||
|
||||
[extract_messages]
|
||||
keywords = translate isPlural
|
||||
add_comments = TRANSLATORS:
|
||||
output_file = ckanext/d4science/i18n/ckanext-d4science.pot
|
||||
width = 80
|
||||
|
||||
[init_catalog]
|
||||
domain = ckanext-d4science
|
||||
input_file = ckanext/d4science/i18n/ckanext-d4science.pot
|
||||
output_dir = ckanext/d4science/i18n
|
||||
|
||||
[update_catalog]
|
||||
domain = ckanext-d4science
|
||||
input_file = ckanext/d4science/i18n/ckanext-d4science.pot
|
||||
output_dir = ckanext/d4science/i18n
|
||||
previous = true
|
||||
|
||||
[compile_catalog]
|
||||
domain = ckanext-d4science
|
||||
directory = ckanext/d4science/i18n
|
||||
statistics = true
|
||||
|
||||
[tool:pytest]
|
||||
filterwarnings =
|
||||
ignore::sqlalchemy.exc.SADeprecationWarning
|
||||
ignore::sqlalchemy.exc.SAWarning
|
||||
ignore::DeprecationWarning
|
||||
addopts = --ckan-ini test.ini
|
|
@ -0,0 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
# If you are changing from the default layout of your extension, you may
|
||||
# have to change the message extractors, you can read more about babel
|
||||
# message extraction at
|
||||
# http://babel.pocoo.org/docs/messages/#extraction-method-mapping-and-configuration
|
||||
message_extractors={
|
||||
'ckanext': [
|
||||
('**.py', 'python', None),
|
||||
('**.js', 'javascript', None),
|
||||
('**/templates/**.html', 'ckan', None),
|
||||
],
|
||||
}
|
||||
)
|
|
@ -0,0 +1,45 @@
|
|||
[DEFAULT]
|
||||
debug = false
|
||||
smtp_server = localhost
|
||||
error_email_from = ckan@localhost
|
||||
|
||||
[app:main]
|
||||
use = config:../../src/ckan/test-core.ini
|
||||
|
||||
# Insert any custom config settings to be used when running your extension's
|
||||
# tests here. These will override the one defined in CKAN core's test-core.ini
|
||||
ckan.plugins = d4science
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root, ckan, sqlalchemy
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_ckan]
|
||||
qualname = ckan
|
||||
handlers =
|
||||
level = INFO
|
||||
|
||||
[logger_sqlalchemy]
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
level = WARN
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>ckanext-d4science_theme</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.python.pydev.PyDevBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.python.pydev.pythonNature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
|
@ -0,0 +1,2 @@
|
|||
eclipse.preferences.version=1
|
||||
encoding/setup.py=utf-8
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,2 @@
|
|||
include README.rst
|
||||
recursive-include ckanext/d4science_theme *.html *.json *.js *.less *.css
|
|
@ -0,0 +1,129 @@
|
|||
|
||||
============
|
||||
ckanext-d4science_theme
|
||||
============
|
||||
|
||||
The CKAN extension that implements the D4Science theme template used by D4Science Catalogues
|
||||
|
||||
|
||||
------------
|
||||
Requirements
|
||||
------------
|
||||
|
||||
None
|
||||
|
||||
------------
|
||||
Installation
|
||||
------------
|
||||
|
||||
To install ckanext-d4science_theme:
|
||||
|
||||
1. Activate your CKAN virtual environment, for example::
|
||||
|
||||
. /usr/lib/ckan/default/bin/activate
|
||||
|
||||
2. Install the ckanext-d4science_theme Python package into your virtual environment::
|
||||
|
||||
pip install ckanext-d4science_theme
|
||||
|
||||
3. Add ``d4science_theme`` to the ``ckan.plugins`` setting in your CKAN
|
||||
config file (by default the config file is located at
|
||||
``/etc/ckan/default/production.ini``).
|
||||
|
||||
4. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu::
|
||||
|
||||
sudo service apache2 reload
|
||||
|
||||
|
||||
---------------
|
||||
Config Settings
|
||||
---------------
|
||||
|
||||
Document any optional config settings here. For example::
|
||||
|
||||
# The minimum number of hours to wait before re-checking a resource
|
||||
# (optional, default: 24).
|
||||
ckanext.d4science_theme.some_setting = some_default_value
|
||||
|
||||
|
||||
------------------------
|
||||
Development Installation
|
||||
------------------------
|
||||
|
||||
To install ckanext-d4science_theme for development, activate your CKAN virtualenv and
|
||||
do::
|
||||
|
||||
git clone https://code-repo.d4science.org/CKAN-Extensions/ckanext-d4science_theme.git
|
||||
cd ckanext-d4science_theme
|
||||
python setup.py develop
|
||||
pip install -r dev-requirements.txt
|
||||
|
||||
|
||||
-----------------
|
||||
Running the Tests
|
||||
-----------------
|
||||
|
||||
To run the tests, do::
|
||||
|
||||
nosetests --nologcapture --with-pylons=test.ini
|
||||
|
||||
To run the tests and produce a coverage report, first make sure you have
|
||||
coverage installed in your virtualenv (``pip install coverage``) then run::
|
||||
|
||||
nosetests --nologcapture --with-pylons=test.ini --with-coverage --cover-package=ckanext.d4science_theme --cover-inclusive --cover-erase --cover-tests
|
||||
|
||||
|
||||
---------------------------------
|
||||
Registering ckanext-d4science_theme on PyPI
|
||||
---------------------------------
|
||||
|
||||
ckanext-d4science_theme should be availabe on PyPI as
|
||||
https://pypi.python.org/pypi/ckanext-d4science_theme. If that link doesn't work, then
|
||||
you can register the project on PyPI for the first time by following these
|
||||
steps:
|
||||
|
||||
1. Create a source distribution of the project::
|
||||
|
||||
python setup.py sdist
|
||||
|
||||
2. Register the project::
|
||||
|
||||
python setup.py register
|
||||
|
||||
3. Upload the source distribution to PyPI::
|
||||
|
||||
python setup.py sdist upload
|
||||
|
||||
4. Tag the first release of the project on GitHub with the version number from
|
||||
the ``setup.py`` file. For example if the version number in ``setup.py`` is
|
||||
0.0.1 then do::
|
||||
|
||||
git tag 0.0.1
|
||||
git push --tags
|
||||
|
||||
|
||||
----------------------------------------
|
||||
Releasing a New Version of ckanext-d4science_theme
|
||||
----------------------------------------
|
||||
|
||||
ckanext-d4science_theme is availabe on PyPI as https://pypi.python.org/pypi/ckanext-d4science_theme.
|
||||
To publish a new version to PyPI follow these steps:
|
||||
|
||||
1. Update the version number in the ``setup.py`` file.
|
||||
See `PEP 440 <http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers>`_
|
||||
for how to choose version numbers.
|
||||
|
||||
2. Create a source distribution of the new version::
|
||||
|
||||
python setup.py sdist
|
||||
|
||||
3. Upload the source distribution to PyPI::
|
||||
|
||||
python setup.py sdist upload
|
||||
|
||||
4. Tag the new release of the project on GitHub with the version number from
|
||||
the ``setup.py`` file. For example if the version number in ``setup.py`` is
|
||||
0.0.2 then do::
|
||||
|
||||
git tag 0.0.2
|
||||
git push --tags
|
|
@ -0,0 +1,7 @@
|
|||
# this is a namespace package
|
||||
try:
|
||||
import pkg_resources
|
||||
pkg_resources.declare_namespace(__name__)
|
||||
except ImportError:
|
||||
import pkgutil
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
|
@ -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;
|
||||
},
|
||||
|
||||
};
|
||||
});
|
|
@ -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 */
|
|
@ -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"
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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');
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
@ -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)
|
|
@ -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")
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -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
|
|
@ -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)
|
|
@ -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 |
|
@ -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)
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
{% ckan_extends %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
{% asset 'ckanext-activity/activity-css' %}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,6 @@
|
|||
{% ckan_extends %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
{% asset "ckanext-activity/activity" %}
|
||||
{% endblock scripts %}
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
||||
<a href="{{ h.url_for('activity.group_changes', id=activity.id) }}">
|
||||
{{ _('Changes') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
|
@ -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 %}
|
||||
|
|
||||
<a href="{{ h.url_for('activity.organization_changes', id=activity.id) }}">
|
||||
{{ _('Changes') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
|
@ -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 %}
|
||||
|
|
||||
<a href="{{ h.url_for('activity.package_history', id=activity.object_id, activity_id=activity.id) }}">
|
||||
{{ _('View this version') }}
|
||||
</a>
|
||||
|
|
||||
<a href="{{ h.url_for('activity.package_changes', id=activity.id) }}">
|
||||
{{ _('Changes') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue