Commit 458b8401 authored by Federico Ceratto's avatar Federico Ceratto

New upstream version 1.6.0

parent 48d8b1b5
......@@ -18,6 +18,7 @@ Aron Pammer <info@aronpammer.me>
Asher256 <Asher256@users.noreply.github.com>
Bancarel Valentin <bancarel.valentin@gmail.com>
Ben Brown <ben.brown@codethink.co.uk>
btmanm <btmanm@users.noreply.github.com>
Carlo Mion <mion00@users.noreply.github.com>
Carlos Soriano <csoriano@gnome.org>
Christian <cgumpert@users.noreply.github.com>
......@@ -27,6 +28,7 @@ Cosimo Lupo <cosimo.lupo@daltonmaag.com>
Crestez Dan Leonard <lcrestez@ixiacom.com>
Cyril Jouve <jv.cyril@gmail.com>
Daniel Kimsey <dekimsey@ufl.edu>
David Guest <owmtia@gmail.com>
derek-austin <derek.austin35@mailinator.com>
Diego Giovane Pasqualin <dpasqualin@c3sl.ufpr.br>
Dmytro Litvinov <litvinov.do.it@gmail.com>
......@@ -61,6 +63,7 @@ Mart Sõmermaa <mart.somermaa@cgi.com>
massimone88 <stefano.mandruzzato@gmail.com>
Matej Zerovnik <matej@zunaj.si>
Matt Odden <locke105@gmail.com>
Matthias Schmitz <matthias@sigxcpu.org>
Matus Ferech <matus.ferech@telekom.com>
Maura Hausman <mhausman@wayfair.com>
Maxime Guyot <maxime.guyot@elits.com>
......@@ -95,6 +98,8 @@ Stefan Klug <klug.stefan@gmx.de>
Stefano Mandruzzato <stefano.mandruzzato@gmail.com>
THEBAULT Julien <julien@thebault.co>
Tim Neumann <mail@timnn.me>
Tom Downes <tpdownes@users.noreply.github.com>
Twan <tmeynen@inuits.eu>
Will Rouesnel <w.rouesnel@gmail.com>
Will Starms <vilhelmen@gmail.com>
Yosi Zelensky <yosyos04@gmail.com>
ChangeLog
=========
Version 1.6.0_ - 2018-08-25
---------------------------
* [docs] Don't use hardcoded values for ids
* [docs] Improve the snippets examples
* [cli] Output: handle bytes in API responses
* [cli] Fix the case where we have nothing to print
* Project import: fix the override_params parameter
* Support group and global MR listing
* Implement MR.pipelines()
* MR: add the squash attribute for create/update
* Added support for listing forks of a project
* [docs] Add/update notes about read-only objects
* Raise an exception on https redirects for PUT/POST
* [docs] Add a FAQ
* [cli] Fix the project-export download
Version 1.5.1_ - 2018-06-23
---------------------------
......@@ -643,7 +660,8 @@ Version 0.1 - 2013-07-08
* Initial release
.. _1.5.1: https://github.com/python-gitlab/python-gitlab/compare/1.4.0...1.5.1
.. _1.6.0: https://github.com/python-gitlab/python-gitlab/compare/1.5.1...1.6.0
.. _1.5.1: https://github.com/python-gitlab/python-gitlab/compare/1.5.0...1.5.1
.. _1.5.0: https://github.com/python-gitlab/python-gitlab/compare/1.4.0...1.5.0
.. _1.4.0: https://github.com/python-gitlab/python-gitlab/compare/1.3.0...1.4.0
.. _1.3.0: https://github.com/python-gitlab/python-gitlab/compare/1.2.0...1.3.0
......
......@@ -4,6 +4,15 @@ Release notes
This page describes important changes between python-gitlab releases.
Changes from 1.5 to 1.6
=======================
* When python-gitlab detects HTTP redirections from http to https it will raise
a RedirectionError instead of a cryptic error.
Make sure to use an ``https://`` protocol in your GitLab URL parameter if the
server requires it.
Changes from 1.4 to 1.5
=======================
......@@ -14,6 +23,7 @@ Changes from 1.4 to 1.5
configuration, epics.
* The ``GetFromListMixin`` class has been removed. The ``get()`` method is not
available anymore for the following managers:
- UserKeyManager
- DeployKeyManager
- GroupAccessRequestManager
......@@ -27,6 +37,7 @@ Changes from 1.4 to 1.5
- ProjectPipelineJobManager
- ProjectAccessRequestManager
- TodoManager
* ``ProjectPipelineJob`` do not heritate from ``ProjectJob`` anymore and thus
can only be listed.
......
......@@ -7,7 +7,7 @@ python-gitlab supports both GitLab v3 and v4 APIs. To use the v3 make sure to
.. note::
To use the v3 make sure to install python-gitlab 1.4. Only the v4 API is
documented here. See the documentation of earlier version for the v3 API.
documented here. See the documentation of earlier versions for the v3 API.
``gitlab.Gitlab`` class
=======================
......@@ -88,7 +88,7 @@ Examples:
You can list the mandatory and optional attributes for object creation and
update with the manager's ``get_create_attrs()`` and ``get_update_attrs()``
methods. They return 2 tuples, the first one is the list of mandatory
attributes, the second one the list of optional attribute:
attributes, the second one is the list of optional attribute:
.. code-block:: python
......@@ -206,7 +206,7 @@ through a large number of items:
for item in items:
print(item.attributes)
The generator exposes extra listing information as received by the server:
The generator exposes extra listing information as received from the server:
* ``current_page``: current page number (first page is 1)
* ``prev_page``: if ``None`` the current page is the first one
......@@ -249,7 +249,7 @@ properly closed when you exit a ``with`` block:
.. warning::
The context manager will also close the custom ``Session`` object you might
have used to build a ``Gitlab`` instance.
have used to build the ``Gitlab`` instance.
Proxy configuration
-------------------
......
......@@ -69,7 +69,7 @@ parameters. You can override the values in each GitLab server section.
- Integer
- Number of seconds to wait for an answer before failing.
* - ``api_version``
- ``3`` ou ``4``
- ``3`` or ``4``
- The API version to use to make queries. Requires python-gitlab >= 1.3.0.
* - ``per_page``
- Integer between 1 and 100
......
###
FAQ
###
I cannot edit the merge request / issue I've just retrieved
It is likely that you used a ``MergeRequest``, ``GroupMergeRequest``,
``Issue`` or ``GroupIssue`` object. These objects cannot be edited. But you
can create a new ``ProjectMergeRequest`` or ``ProjectIssue`` object to
apply changes. For example::
issue = gl.issues.list()[0]
project = gl.projects.get(issue.project_id, lazy=True)
editable_issue = project.issues.get(issue.iid, lazy=True)
# you can now edit the object
See the :ref:`merge requests example <merge_requests_examples>` and the
:ref:`issues examples <issues_examples>`.
How can I clone the repository of a project?
python-gitlab doesn't provide an API to clone a project. You have to use a
git library or call the ``git`` command.
The git URI is exposed in the ``ssh_url_to_repo`` attribute of ``Project``
objects.
Example::
import subprocess
project = gl.projects.create(data) # or gl.projects.get(project_id)
print(project.attributes) # displays all the attributes
git_url = project.ssh_url_to_repo
subprocess.call(['git', 'clone', git_url])
......@@ -92,7 +92,7 @@ Full example with wait for finish::
pipeline = project.trigger_pipeline('master', trigger.token, variables={"DEPLOY_ZONE": "us-west1"})
while pipeline.finished_at is None:
pipeline.refresh()
os.sleep(1)
time.sleep(1)
Pipeline schedule
=================
......
......@@ -85,7 +85,7 @@ Reference
+ :class:`gitlab.v4.objects.ProjectCommitComment`
+ :class:`gitlab.v4.objects.ProjectCommitCommentManager`
+ :attr:`gitlab.v4.objects.Commit.comments`
+ :attr:`gitlab.v4.objects.ProjectCommit.comments`
* GitLab API: https://docs.gitlab.com/ce/api/commits.html
......@@ -116,7 +116,7 @@ Reference
+ :class:`gitlab.v4.objects.ProjectCommitStatus`
+ :class:`gitlab.v4.objects.ProjectCommitStatusManager`
+ :attr:`gitlab.v4.objects.Commit.statuses`
+ :attr:`gitlab.v4.objects.ProjectCommit.statuses`
* GitLab API: https://docs.gitlab.com/ce/api/commits.html
......
.. _issues_examples:
######
Issues
######
......@@ -30,6 +32,17 @@ Use the ``state`` and ``label`` parameters to filter the results. Use the
closed_issues = gl.issues.list(state='closed')
tagged_issues = gl.issues.list(labels=['foo', 'bar'])
.. note::
It is not possible to edit or delete Issue objects. You need to create a
ProjectIssue object to perform changes::
issue = gl.issues.list()[0]
project = gl.projects.get(issue.project_id, lazy=True)
editable_issue = project.issues.get(issue.iid, lazy=True)
editable_issue.title = updated_title
editable_issue.save()
Group issues
============
......@@ -55,6 +68,17 @@ List the group issues::
# Order using the order_by and sort parameters
issues = group.issues.list(order_by='created_at', sort='desc')
.. note::
It is not possible to edit or delete GroupIssue objects. You need to create
a ProjectIssue object to perform changes::
issue = group.issues.list()[0]
project = gl.projects.get(issue.project_id, lazy=True)
editable_issue = project.issues.get(issue.iid, lazy=True)
editable_issue.title = updated_title
editable_issue.save()
Project issues
==============
......
.. _merge_requests_examples:
##############
Merge requests
##############
......@@ -5,6 +7,53 @@ Merge requests
You can use merge requests to notify a project that a branch is ready for
merging. The owner of the target projet can accept the merge request.
Merge requests are linked to projects, but they can be listed globally or for
groups.
Group and global listing
========================
Reference
---------
* v4 API:
+ :class:`gitlab.v4.objects.GroupMergeRequest`
+ :class:`gitlab.v4.objects.GroupMergeRequestManager`
+ :attr:`gitlab.v4.objects.Group.mergerequests`
+ :class:`gitlab.v4.objects.MergeRequest`
+ :class:`gitlab.v4.objects.MergeRequestManager`
+ :attr:`gitlab.Gtilab.mergerequests`
* GitLab API: https://docs.gitlab.com/ce/api/merge_requests.html
Examples
--------
List the merge requests available on the GitLab server::
mrs = gl.mergerequests.list()
List the merge requests for a group::
group = gl.groups.get('mygroup')
mrs = group.mergerequests.list()
.. note::
It is not possible to edit or delete ``MergeRequest`` and
``GroupMergeRequest`` objects. You need to create a ``ProjectMergeRequest``
object to apply changes::
mr = group.mergerequests.list()[0]
project = gl.projects.get(mr.project_id, lazy=True)
editable_mr = project.mergerequests.get(mr.iid, lazy=True)
editable_mr.title = updated_title
editable_mr.save()
Project merge requests
======================
Reference
---------
......@@ -74,6 +123,14 @@ List commits of a MR::
commits = mr.commits()
List the changes of a MR::
changes = mr.changes()
List the pipelines for a MR::
pipelines = mr.pipelines()
List issues that will close on merge::
mr.closes_issues()
......
......@@ -56,7 +56,7 @@ Results can also be sorted using the following parameters:
Get a single project::
# Get a project by ID
project = gl.projects.get(10)
project = gl.projects.get(project_id)
# Get a project by userspace/name
project = gl.projects.get('myteam/myproject')
......@@ -84,7 +84,7 @@ Update a project::
Delete a project::
gl.projects.delete(1)
gl.projects.delete(project_id)
# or
project.delete()
......@@ -95,6 +95,10 @@ Fork a project::
# fork to a specific namespace
fork = project.forks.create({'namespace': 'myteam'})
Get a list of forks for the project::
forks = project.forks.list()
Create/delete a fork relation between projects (requires admin permissions)::
project.create_fork_relation(source_project.id)
......@@ -288,7 +292,7 @@ Delete a custom attribute for a project::
Search projects by custom attribute::
project.customattributes.set('type': 'internal')
project.customattributes.set('type', 'internal')
gl.projects.list(custom_attributes={'type': 'internal'})
Project files
......@@ -480,7 +484,7 @@ Search project members matching a query string::
Get a single project member::
member = project.members.get(1)
member = project.members.get(user_id)
Add a project member::
......@@ -526,7 +530,7 @@ List the project hooks::
Get a project hook::
hook = project.hooks.get(1)
hook = project.hooks.get(hook_id)
Create a project hook::
......@@ -539,7 +543,7 @@ Update a project hook::
Delete a project hook::
project.hooks.delete(1)
project.hooks.delete(hook_id)
# or
hook.delete()
......
......@@ -9,7 +9,7 @@ Reference
+ :class:`gitlab.v4.objects.Snippet`
+ :class:`gitlab.v4.objects.SnipptManager`
+ :attr:`gilab.Gitlab.snippets`
+ :attr:`gitlab.Gitlab.snippets`
* GitLab API: https://docs.gitlab.com/ce/api/snippets.html
......@@ -42,11 +42,19 @@ Create a snippet::
'file_name': 'snippet1.py',
'content': open('snippet1.py').read()})
Update a snippet::
Update the snippet attributes::
snippet.visibility_level = gitlab.Project.VISIBILITY_PUBLIC
snippet.save()
To update a snippet code you need to create a ``ProjectSnippet`` object:
snippet = gl.snippets.get(snippet_id)
project = gl.projects.get(snippet.projec_id, lazy=True)
editable_snippet = project.snippets.get(snippet.id)
editable_snippet.code = new_snippet_content
editable_snippet.save()
Delete a snippet::
gl.snippets.delete(snippet_id)
......
......@@ -35,7 +35,7 @@ Search users whose username match a given string::
Get a single user::
# by ID
user = gl.users.get(2)
user = gl.users.get(user_id)
# by username
user = gl.users.list(username='root')[0]
......@@ -53,7 +53,8 @@ Update a user::
Delete a user::
gl.users.delete(2)
gl.users.delete(user_id)
# or
user.delete()
Block/Unblock a user::
......@@ -71,7 +72,7 @@ Set the avatar image for a user::
Set an external identity for a user::
user.provider = 'oauth2_generic'
user..extern_uid = '3'
user.extern_uid = '3'
user.save()
User custom attributes
......@@ -198,7 +199,7 @@ List GPG keys for a user::
Get a GPG gpgkey for a user::
gpgkey = user.gpgkeys.get(1)
gpgkey = user.gpgkeys.get(key_id)
Create a GPG gpgkey for a user::
......@@ -207,7 +208,7 @@ Create a GPG gpgkey for a user::
Delete a GPG gpgkey for a user::
user.gpgkeys.delete(1)
user.gpgkeys.delete(key_id)
# or
gpgkey.delete()
......@@ -245,7 +246,7 @@ Create an SSH key for a user::
Delete an SSH key for a user::
user.keys.delete(1)
user.keys.delete(key_id)
# or
key.delete()
......@@ -278,9 +279,7 @@ List emails for a user::
Get an email for a user::
email = gl.user_emails.list(1, user_id=1)
# or
email = user.emails.get(1)
email = user.emails.get(email_id)
Create an email for a user::
......@@ -288,7 +287,7 @@ Create an email for a user::
Delete an email for a user::
user.emails.delete(1)
user.emails.delete(email_id)
# or
email.delete()
......
......@@ -14,6 +14,7 @@ Contents:
install
cli
api-usage
faq
switching-to-v4
api-objects
api/gitlab
......
......@@ -28,9 +28,10 @@ import six
import gitlab.config
from gitlab.const import * # noqa
from gitlab.exceptions import * # noqa
from gitlab import utils # noqa
__title__ = 'python-gitlab'
__version__ = '1.5.1'
__version__ = '1.6.0'
__author__ = 'Gauvain Pocentek'
__email__ = 'gauvain@pocentek.net'
__license__ = 'LGPL3'
......@@ -39,6 +40,9 @@ __copyright__ = 'Copyright 2013-2018 Gauvain Pocentek'
warnings.filterwarnings('default', category=DeprecationWarning,
module='^gitlab')
REDIRECT_MSG = ('python-gitlab detected an http to https redirection. You '
'must update your GitLab URL to use https:// to avoid issues.')
def _sanitize(value):
if isinstance(value, dict):
......@@ -114,6 +118,7 @@ class Gitlab(object):
self.ldapgroups = objects.LDAPGroupManager(self)
self.licenses = objects.LicenseManager(self)
self.namespaces = objects.NamespaceManager(self)
self.mergerequests = objects.MergeRequestManager(self)
self.notificationsettings = objects.NotificationSettingsManager(self)
self.projects = objects.ProjectManager(self)
self.runners = objects.RunnerManager(self)
......@@ -393,6 +398,26 @@ class Gitlab(object):
else:
return '%s%s' % (self._url, path)
def _check_redirects(self, result):
# Check the requests history to detect http to https redirections.
# If the initial verb is POST, the next request will use a GET request,
# leading to an unwanted behaviour.
# If the initial verb is PUT, the data will not be send with the next
# request.
# If we detect a redirection to https with a POST or a PUT request, we
# raise an exception with a useful error message.
if result.history and self._base_url.startswith('http:'):
for item in result.history:
if item.status_code not in (301, 302):
continue
# GET methods can be redirected without issue
if result.request.method == 'GET':
continue
# Did we end-up with an https:// URL?
location = item.headers.get('Location', None)
if location and location.startswith('https://'):
raise RedirectError(REDIRECT_MSG)
def http_request(self, verb, path, query_data={}, post_data=None,
streamed=False, files=None, **kwargs):
"""Make an HTTP request to the Gitlab server.
......@@ -416,27 +441,11 @@ class Gitlab(object):
GitlabHttpError: When the return code is not 2xx
"""
def sanitized_url(url):
parsed = six.moves.urllib.parse.urlparse(url)
new_path = parsed.path.replace('.', '%2E')
return parsed._replace(path=new_path).geturl()
url = self._build_url(path)
def copy_dict(dest, src):
for k, v in src.items():
if isinstance(v, dict):
# Transform dict values in new attributes. For example:
# custom_attributes: {'foo', 'bar'} =>
# custom_attributes['foo']: 'bar'
for dict_k, dict_v in v.items():
dest['%s[%s]' % (k, dict_k)] = dict_v
else:
dest[k] = v
params = {}
copy_dict(params, query_data)
copy_dict(params, kwargs)
utils.copy_dict(params, query_data)
utils.copy_dict(params, kwargs)
opts = self._get_session_opts(content_type='application/json')
......@@ -461,7 +470,7 @@ class Gitlab(object):
req = requests.Request(verb, url, json=json, data=data, params=params,
files=files, **opts)
prepped = self.session.prepare_request(req)
prepped.url = sanitized_url(prepped.url)
prepped.url = utils.sanitized_url(prepped.url)
settings = self.session.merge_environment_settings(
prepped.url, {}, streamed, verify, None)
......@@ -471,6 +480,8 @@ class Gitlab(object):
while True:
result = self.session.send(prepped, timeout=timeout, **settings)
self._check_redirects(result)
if 200 <= result.status_code < 300:
return result
......
......@@ -41,6 +41,10 @@ class GitlabAuthenticationError(GitlabError):
pass
class RedirectError(GitlabError):
pass
class GitlabParsingError(GitlabError):
pass
......
......@@ -15,6 +15,8 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import six
class _StdoutStream(object):
def __call__(self, chunk):
......@@ -31,3 +33,21 @@ def response_content(response, streamed, action, chunk_size):
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
action(chunk)
def copy_dict(dest, src):
for k, v in src.items():
if isinstance(v, dict):
# Transform dict values to new attributes. For example:
# custom_attributes: {'foo', 'bar'} =>
# "custom_attributes['foo']": "bar"
for dict_k, dict_v in v.items():
dest['%s[%s]' % (k, dict_k)] = dict_v
else:
dest[k] = v
def sanitized_url(url):
parsed = six.moves.urllib.parse.urlparse(url)
new_path = parsed.path.replace('.', '%2E')
return parsed._replace(path=new_path).geturl()
......@@ -19,6 +19,7 @@
from __future__ import print_function
import inspect
import operator
import sys
import six
......@@ -54,11 +55,18 @@ class GitlabCLI(object):
self.args[attr_name] = obj.get()
def __call__(self):
# Check for a method that matches object + action
method = 'do_%s_%s' % (self.what, self.action)
if hasattr(self, method):
return getattr(self, method)()
# Fallback to standard actions (get, list, create, ...)
method = 'do_%s' % self.action
if hasattr(self, method):
return getattr(self, method)()
else:
return self.do_custom()
# Finally try to find custom methods
return self.do_custom()
def do_custom(self):
in_obj = cli.custom_actions[self.cls_name][self.action][2]
......@@ -77,6 +85,20 @@ class GitlabCLI(object):
else:
return getattr(self.mgr, self.action)(**self.args)
def do_project_export_download(self):
try:
project = self.gl.projects.get(int(self.args['project_id']),
lazy=True)
data = project.exports.get().download()
if hasattr(sys.stdout, 'buffer'):
# python3
sys.stdout.buffer.write(data)
else:
sys.stdout.write(data)
except Exception as e:
cli.die("Impossible to download the export", e)
def do_create(self):
try:
return self.mgr.create(self.args)
......@@ -366,3 +388,5 @@ def run(gl, what, action, args, verbose, output, fields):
printer.display(get_dict(data, fields), verbose=verbose, obj=data)
elif isinstance(data, six.string_types):
print(data)
elif hasattr(data, 'decode'):
print(data.decode())
......@@ -710,8 +710,16 @@ class GroupMergeRequest(RESTObject):
pass
class GroupMergeRequestManager(RESTManager):
pass
class GroupMergeRequestManager(ListMixin, RESTManager):
_path = '/groups/%(group_id)s/merge_requests'
_obj_cls = GroupMergeRequest
_from_parent_attrs = {'group_id': 'id'}
_list_filters = ('state', 'order_by', 'sort', 'milestone', 'view',
'labels', 'created_after', 'created_before',
'updated_after', 'updated_before', 'scope', 'author_id',
'assignee_id', 'my_reaction_emoji', 'source_branch',
'target_branch', 'search')
_types = {'labels': types.ListAttribute}
class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject):
......@@ -842,6 +850,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
('epics', 'GroupEpicManager'),
('issues', 'GroupIssueManager'),
('members', 'GroupMemberManager'),
('mergerequests', 'GroupMergeRequestManager'),
('milestones', 'GroupMilestoneManager'),
('notificationsettings', 'GroupNotificationSettingsManager'),
('projects', 'GroupProjectManager'),
......@@ -1040,6 +1049,22 @@ class LicenseManager(RetrieveMixin, RESTManager):
_optional_get_attrs = ('project', 'fullname')
class MergeRequest(RESTObject):
pass
class MergeRequestManager(ListMixin, RESTManager):
_path = '/merge_requests'
_obj_cls = MergeRequest
_from_parent_attrs = {'group_id': 'id'}
_list_filters = ('state', 'order_by', 'sort', 'milestone', 'view',
'labels', 'created_after', 'created_before',
'updated_after', 'updated_before', 'scope', 'author_id',
'assignee_id', 'my_reaction_emoji', 'source_branch',
'target_branch', 'search')
_types = {'labels': types.ListAttribute}
class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject):
_short_print_attr = 'title'
......@@ -1620,7 +1645,7 @@ class ProjectFork(RESTObject):
pass
class ProjectForkManager(CreateMixin, RESTManager):
class ProjectForkManager(CreateMixin, ListMixin, RESTManager):
_path = '/projects/%(project_id)s/fork'
_obj_cls = ProjectFork
_from_parent_attrs = {'project_id': 'id'}
......@@ -1630,6 +1655,28 @@ class ProjectForkManager(CreateMixin, RESTManager):
'with_merge_requests_enabled')
_create_attrs = (tuple(), ('namespace', ))
def list(self, **kwargs):
"""Retrieve a list of objects.
Args:
all (bool): If True, return all the items, without pagination
per_page (int): Number of items to retrieve per request
page (int): ID of the page to return (starts with page 1)
as_list (bool): If set to False and no pagination option is
defined, return a generator instead of a list
**kwargs: Extra options to send to the server (e.g. sudo)
Returns:
list: The list of objects, or a generator if `as_list` is False
Raises:
GitlabAuthenticationError: If authentication is not correct
GitlabListError: If the server cannot perform the request
"""
path = self._compute_path('/projects/%(project_id)s/forks')
return ListMixin.list(self, path=path, **kwargs)
class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject):
_short_print_attr = 'url'
......@@ -2132,6 +2179,24 @@ class ProjectMe