Merge lp:~jskrzeszewska/mailman/mailman into lp:mailman

Proposed by Joanna Skrzeszewska
Status: Needs review
Proposed branch: lp:~jskrzeszewska/mailman/mailman
Merge into: lp:mailman
Diff against target: 553 lines (+513/-0)
5 files modified
src/mailman/archiving/rssarchive.py (+225/-0)
src/mailman/archiving/tests/test_rssarchive.py (+203/-0)
src/mailman/config/mailman.cfg (+4/-0)
src/mailman/rest/root.py (+9/-0)
src/mailman/rest/rss.py (+72/-0)
To merge this branch: bzr merge lp:~jskrzeszewska/mailman/mailman
Reviewer Review Type Date Requested Status
Nicki Hutchens (community) Approve
Nicki Hutchens Pending
Barry Warsaw Pending
Review via email: mp+178414@code.launchpad.net

Description of the change

Added archiver for rss feed and REST API for rss settings.

To post a comment you must log in.
lp:~jskrzeszewska/mailman/mailman updated
7218. By Joanna Skrzeszewska

Changes in rssarchive.py

7219. By Joanna Skrzeszewska

Tests for rssarchive

Revision history for this message
Nicki Hutchens (nhutch01) :
review: Approve

Unmerged revisions

7219. By Joanna Skrzeszewska

Tests for rssarchive

7218. By Joanna Skrzeszewska

Changes in rssarchive.py

7217. By Joanna Skrzeszewska

Adding REST API for rss archiver.

7216. By Joanna Skrzeszewska

Adding rss archiver.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/mailman/archiving/rssarchive.py'
2--- src/mailman/archiving/rssarchive.py 1970-01-01 00:00:00 +0000
3+++ src/mailman/archiving/rssarchive.py 2013-09-01 16:37:50 +0000
4@@ -0,0 +1,225 @@
5+# Copyright (C) 2008-2013 by the Free Software Foundation, Inc.
6+#
7+# This file is part of GNU Mailman.
8+#
9+# GNU Mailman is free software: you can redistribute it and/or modify it under
10+# the terms of the GNU General Public License as published by the Free
11+# Software Foundation, either version 3 of the License, or (at your option)
12+# any later version.
13+#
14+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
15+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17+# more details.
18+#
19+# You should have received a copy of the GNU General Public License along with
20+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
21+
22+from __future__ import absolute_import, print_function, unicode_literals
23+
24+__metaclass__ = type
25+__all__ = [
26+ 'RssArchiver',
27+ ]
28+
29+import os
30+import errno
31+import logging
32+import datetime
33+import PyRSS2Gen
34+import sqlite3
35+
36+from email.utils import parsedate
37+from datetime import datetime
38+from operator import itemgetter
39+from datetime import timedelta
40+from mailbox import Maildir
41+from urlparse import urljoin
42+
43+from flufl.lock import Lock, TimeOutError
44+from zope.interface import implementer
45+from mailman.interfaces.listmanager import IListManager
46+from zope.component import getUtility
47+
48+from mailman.config import config
49+from mailman.interfaces.archiver import IArchiver
50+
51+log = logging.getLogger('mailman.error')
52+
53+@implementer(IArchiver)
54+class RssArchiver:
55+ name = 'rssarchive'
56+ table_name = 'admin_features'
57+
58+ @staticmethod
59+ def set_length_limit(mlist, length_limit):
60+ rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
61+ is_enabled = RssArchiver.is_feed_enabled(mlist)
62+ conn = sqlite3.connect(rss_db_dir)
63+ c = conn.cursor()
64+ sql = ('insert or replace into ' + RssArchiver.table_name +
65+ ' values (\'' + mlist.fqdn_listname + '\', ' + str(is_enabled) +
66+ ', ' + str(length_limit) + ')')
67+ c.execute(sql)
68+ conn.commit()
69+ c.close()
70+ conn.close()
71+
72+ @staticmethod
73+ def set_feed_enabled(mlist, is_enabled):
74+ rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
75+ conn = sqlite3.connect(rss_db_dir)
76+ c = conn.cursor()
77+ length_limit = RssArchiver.get_length_limit(mlist)
78+ sql = ('insert or replace into ' + RssArchiver.table_name +
79+ ' values (\'' + mlist.fqdn_listname + '\', ' + str(is_enabled) +
80+ ', ' +str(length_limit) + ')')
81+ c.execute(sql)
82+ conn.commit()
83+ c.close()
84+ conn.close()
85+ if is_enabled:
86+ RssArchiver.generate_rss_feed(mlist)
87+
88+ @staticmethod
89+ def get_length_limit(mlist):
90+ rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
91+ RssArchiver.init_database()
92+ conn = sqlite3.connect(rss_db_dir)
93+ c = conn.cursor()
94+ sql = ('select entries_no from ' + RssArchiver.table_name +
95+ ' where list_name = \'' + mlist.fqdn_listname + '\'')
96+ is_enabled = c.execute(sql).fetchone()
97+ if is_enabled == None:
98+ is_enabled = [100]
99+ conn.commit()
100+ c.close()
101+ conn.close()
102+ return is_enabled[0]
103+
104+ @staticmethod
105+ def is_feed_enabled(mlist):
106+ rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
107+ RssArchiver.init_database()
108+ conn = sqlite3.connect(rss_db_dir)
109+ c = conn.cursor()
110+ sql = ('select is_switched_on from ' + RssArchiver.table_name +
111+ ' where list_name = \'' + mlist.fqdn_listname + '\'')
112+ is_enabled = c.execute(sql).fetchone()
113+ if is_enabled == None:
114+ is_enabled = [1]
115+ conn.commit()
116+ c.close()
117+ conn.close()
118+ return is_enabled[0]
119+
120+ @staticmethod
121+ def init_database():
122+ rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
123+ conn = sqlite3.connect(rss_db_dir)
124+ c = conn.cursor()
125+ sql = ('create table if not exists ' + RssArchiver.table_name +
126+ ' (list_name text unique, is_switched_on integer, entries_no integer)')
127+ c.execute(sql)
128+ conn.commit()
129+ lists = []
130+ list_manager = getUtility(IListManager)
131+ for fqdn_name in sorted(list_manager.names):
132+ mlist = list_manager.get(fqdn_name)
133+ lists.append(mlist)
134+ for list in lists:
135+ log.error(list.fqdn_listname)
136+ sql = ('insert or ignore into ' + RssArchiver.table_name +
137+ ' values (\'' + list.fqdn_listname + '\', 1, 30)')
138+ c.execute(sql)
139+ conn.commit()
140+ c.close()
141+ conn.close()
142+
143+ @staticmethod
144+ def format_message(msg, msg_as_string):
145+ string_msg = ''
146+ string_msg += 'Date: ' + msg['date'] + '\n'
147+ string_msg += 'From: ' + msg['from'] + '\n'
148+ string_msg += 'To: ' + msg['to'] + '\n\n' + msg_as_string
149+ return string_msg.replace('\n', '<br>').replace(' ', '&nbsp')
150+
151+ @staticmethod
152+ def extract_content(msg):
153+ if msg.is_multipart():
154+ for part in msg.walk():
155+ if part.get_content_type() == 'text/plain':
156+ msg_as_string = part.get_payload + msg_as_string
157+ else:
158+ msg_as_string = msg.get_payload()
159+ return RssArchiver.format_message(msg, msg_as_string)
160+
161+ @staticmethod
162+ def generate_rss_feed(mlist):
163+ archive_dir = os.path.join(config.ARCHIVE_DIR, 'rssarchive')
164+ RssArchiver.create_if_none(archive_dir)
165+ list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
166+ mailbox = Maildir(list_dir, create=True, factory=None)
167+ messages = mailbox.values()
168+ for mes in messages:
169+ mes['parsedate'] = parsedate(mes['date'])
170+ messages.sort(key=itemgetter('parsedate'), reverse=True)
171+ length_limit = RssArchiver.get_length_limit(mlist)
172+ messages = messages[0: length_limit]
173+ rss = PyRSS2Gen.RSS2(
174+ title = mlist.fqdn_listname,
175+ link = "",
176+ description = "The latest messages from: " + mlist.fqdn_listname,
177+ lastBuildDate = datetime.now(),
178+ items = [
179+ PyRSS2Gen.RSSItem(
180+ title = mes['subject'],
181+ description = RssArchiver.extract_content(mes),
182+ pubDate = mes['date'])
183+ for mes in messages
184+ ])
185+ dirPath = os.path.abspath("feeds")
186+ if not os.path.exists(dirPath):
187+ os.makedirs(dirPath)
188+ fileName = mlist.fqdn_listname + '.xml'
189+ filePath = os.path.abspath(os.path.join(dirPath, fileName))
190+ with open(filePath, "w") as f:
191+ rss.write_xml(f)
192+
193+ @staticmethod
194+ def list_url(mlist):
195+ return None
196+
197+ @staticmethod
198+ def permalink(mlist, msg):
199+ return None
200+
201+ @staticmethod
202+ def create_if_none(archive_dir):
203+ try:
204+ os.makedirs(archive_dir, 0775)
205+ except OSError as error:
206+ if error.errno != errno.EEXIST:
207+ raise
208+
209+ @staticmethod
210+ def archive_message(mlist, message):
211+ archive_dir = os.path.join(config.ARCHIVE_DIR, 'rssarchive')
212+ RssArchiver.create_if_none(archive_dir)
213+ list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
214+ mailbox = Maildir(list_dir, create=True, factory=None)
215+ lock_file = os.path.join(
216+ config.LOCK_DIR, '{0}-maildir.lock'.format(mlist.fqdn_listname))
217+ lock = Lock(lock_file)
218+ try:
219+ lock.lock(timeout=timedelta(seconds=1))
220+ mailbox.add(message)
221+ if RssArchiver.is_feed_enabled(mlist):
222+ RssArchiver.generate_rss_feed(mlist);
223+ except TimeOutError:
224+ log.error('Unable to acquire rss archiver lock for {0}, '
225+ 'discarding: {1}'.format(
226+ mlist.fqdn_listname,
227+ message.get('message-id', 'n/a')))
228+ finally:
229+ lock.unlock(unconditionally=True)
230
231=== added file 'src/mailman/archiving/tests/test_rssarchive.py'
232--- src/mailman/archiving/tests/test_rssarchive.py 1970-01-01 00:00:00 +0000
233+++ src/mailman/archiving/tests/test_rssarchive.py 2013-09-01 16:37:50 +0000
234@@ -0,0 +1,203 @@
235+# Copyright (C) 2012-2013 by the Free Software Foundation, Inc.
236+#
237+# This file is part of GNU Mailman.
238+#
239+# GNU Mailman is free software: you can redistribute it and/or modify it under
240+# the terms of the GNU General Public License as published by the Free
241+# Software Foundation, either version 3 of the License, or (at your option)
242+# any later version.
243+#
244+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
245+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
246+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
247+# more details.
248+#
249+# You should have received a copy of the GNU General Public License along with
250+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
251+
252+"""Test the rssarchiver."""
253+
254+from __future__ import absolute_import, print_function, unicode_literals
255+
256+__metaclass__ = type
257+__all__ = [
258+ 'TestRssArchiver',
259+ ]
260+
261+
262+import os
263+import shutil
264+import tempfile
265+import unittest
266+import threading
267+
268+from xml.dom.minidom import parse, parseString
269+from email import message_from_file
270+from flufl.lock import Lock
271+
272+from mailman.app.lifecycle import create_list
273+from mailman.archiving.rssarchive import RssArchiver
274+from mailman.config import config
275+from mailman.database.transaction import transaction
276+from mailman.testing.helpers import LogFileMark
277+from mailman.testing.helpers import (
278+ specialized_message_from_string as mfs)
279+from mailman.testing.layers import ConfigLayer
280+from mailman.utilities.email import add_message_hash
281+
282+
283+class TestRssArchiver(unittest.TestCase):
284+ """Test the rss archiver."""
285+
286+ layer = ConfigLayer
287+
288+ def setUp(self):
289+ # Create a fake mailing list and message object
290+ self._msg = mfs("""\
291+To: test@example.com
292+From: anne@example.com
293+Date: 16-05-2012
294+Subject: Testing the test list
295+Message-ID: <ant>
296+X-Message-ID-Hash: MS6QLWERIJLGCRF44J7USBFDELMNT2BW
297+
298+Tests are better than no tests
299+but the water deserves to be swum.
300+""")
301+ with transaction():
302+ self._mlist = create_list('test@example.com')
303+ # Set up a temporary directory for the prototype archiver so that it's
304+ # easier to clean up.
305+ self._tempdir = tempfile.mkdtemp()
306+ config.push('rssarchive', """
307+ [paths.testing]
308+ archive_dir: {0}
309+ """.format(self._tempdir))
310+ # Capture the structure of a maildir.
311+ self._expected_dir_structure = set(
312+ (os.path.join(config.ARCHIVE_DIR, path) for path in (
313+ 'rssarchive',
314+ os.path.join('rssarchive', self._mlist.fqdn_listname),
315+ os.path.join('rssarchive', self._mlist.fqdn_listname, 'cur'),
316+ os.path.join('rssarchive', self._mlist.fqdn_listname, 'new'),
317+ os.path.join('rssarchive', self._mlist.fqdn_listname, 'tmp'),
318+ )))
319+ self._expected_dir_structure.add(config.ARCHIVE_DIR)
320+
321+ def tearDown(self):
322+ shutil.rmtree(self._tempdir)
323+ config.pop('rssarchive')
324+
325+ def _find(self, path):
326+ all_filenames = set()
327+ for dirpath, dirnames, filenames in os.walk(path):
328+ if not isinstance(dirpath, unicode):
329+ dirpath = unicode(dirpath)
330+ all_filenames.add(dirpath)
331+ for filename in filenames:
332+ new_filename = filename
333+ if not isinstance(filename, unicode):
334+ new_filename = unicode(filename)
335+ all_filenames.add(os.path.join(dirpath, new_filename))
336+ return all_filenames
337+
338+ def test_rssarchiver_xml_file(self):
339+ RssArchiver.archive_message(self._mlist, self._msg)
340+ xml_path = os.path.join('/root/mailman/parts/test/feeds/', (self._mlist.fqdn_listname + '.xml'))
341+
342+ datasource = open(xml_path)
343+ dom = parse(datasource)
344+ rssNode = dom.childNodes[0]
345+ channelNode = rssNode.childNodes[0]
346+
347+ self.assertEqual(len(channelNode.childNodes), 7)
348+
349+ itemNode = channelNode.childNodes[6]
350+ titleNode = itemNode.childNodes[0]
351+ self.assertEqual(titleNode.nodeName, 'title')
352+ self.assertEqual(titleNode.firstChild.nodeValue, 'Testing the test list')
353+
354+ def test_set_length_limit(self):
355+ RssArchiver.set_length_limit(self._mlist, 2)
356+ self.assertEqual(RssArchiver.get_length_limit(self._mlist), 2)
357+
358+ def test_set_feed_enabled(self):
359+ RssArchiver.set_feed_enabled(self._mlist, 1)
360+ self.assertEqual(RssArchiver.is_feed_enabled(self._mlist), 1)
361+ RssArchiver.set_feed_enabled(self._mlist, 0)
362+ self.assertEqual(RssArchiver.is_feed_enabled(self._mlist), 0)
363+
364+ def test_archive_maildir_created(self):
365+ # Archiving a message to the rssarchiver should create the
366+ # expected directory structure.
367+ RssArchiver.archive_message(self._mlist, self._msg)
368+ all_filenames = self._find(config.ARCHIVE_DIR)
369+ # Check that the directory structure has been created and we have one
370+ # more file (the archived message) than expected directories.
371+ archived_messages = [x for x in (all_filenames - self._expected_dir_structure) if 'rss.db' not in x]
372+ self.assertEqual(len(archived_messages), 1)
373+ self.assertTrue(
374+ archived_messages.pop().startswith(
375+ os.path.join(config.ARCHIVE_DIR, 'rssarchive',
376+ self._mlist.fqdn_listname, 'new')))
377+
378+ def test_archive_maildir_existence_does_not_raise(self):
379+ # Archiving a second message does not cause an EEXIST to be raised
380+ # when a second message is archived.
381+ new_dir = None
382+ RssArchiver.archive_message(self._mlist, self._msg)
383+ for directory in ('cur', 'new', 'tmp'):
384+ path = os.path.join(config.ARCHIVE_DIR, 'rssarchive',
385+ self._mlist.fqdn_listname, directory)
386+ if directory == 'new':
387+ new_dir = path
388+ self.assertTrue(os.path.isdir(path))
389+ # There should be one message in the 'new' directory.
390+ self.assertEqual(len(os.listdir(new_dir)), 1)
391+ # Archive a second message. If an exception occurs, let it fail the
392+ # test. Afterward, two messages should be in the 'new' directory.
393+ del self._msg['message-id']
394+ del self._msg['x-message-id-hash']
395+ self._msg['Message-ID'] = '<bee>'
396+ add_message_hash(self._msg)
397+ RssArchiver.archive_message(self._mlist, self._msg)
398+ self.assertEqual(len(os.listdir(new_dir)), 2)
399+
400+ def test_archive_lock_used(self):
401+ # Test that locking the maildir when adding works as a failure here
402+ # could mean we lose mail.
403+ lock_file = os.path.join(
404+ config.LOCK_DIR, '{0}-maildir.lock'.format(
405+ self._mlist.fqdn_listname))
406+ with Lock(lock_file):
407+ # Acquire the archiver lock, then make sure the archiver logs the
408+ # fact that it could not acquire the lock.
409+ archive_thread = threading.Thread(
410+ target=RssArchiver.archive_message,
411+ args=(self._mlist, self._msg))
412+ mark = LogFileMark('mailman.error')
413+ archive_thread.run()
414+ # Test that the archiver output the correct error.
415+ line = mark.readline()
416+ # XXX 2012-03-15 BAW: we really should remove timestamp prefixes
417+ # from the loggers when under test.
418+ self.assertTrue(line.endswith(
419+ 'Unable to acquire rss archiver lock for {0}, '
420+ 'discarding: {1}\n'.format(
421+ self._mlist.fqdn_listname,
422+ self._msg.get('message-id'))))
423+ # Check that the message didn't get archived.
424+ created_files = self._find(config.ARCHIVE_DIR)
425+ self.assertEqual(self._expected_dir_structure, created_files)
426+
427+ def test_rssarchiver_good_path(self):
428+ # Verify the good path; the message gets archived.
429+ RssArchiver.archive_message(self._mlist, self._msg)
430+ new_path = os.path.join(
431+ config.ARCHIVE_DIR, 'rssarchive', self._mlist.fqdn_listname, 'new')
432+ archived_messages = list(os.listdir(new_path))
433+ self.assertEqual(len(archived_messages), 1)
434+ # Check that the email has been added.
435+ with open(os.path.join(new_path, archived_messages[0])) as fp:
436+ archived_message = message_from_file(fp)
437+ self.assertEqual(self._msg.as_string(), archived_message.as_string())
438
439=== modified file 'src/mailman/config/mailman.cfg'
440--- src/mailman/config/mailman.cfg 2013-01-16 23:54:32 +0000
441+++ src/mailman/config/mailman.cfg 2013-09-01 16:37:50 +0000
442@@ -91,3 +91,7 @@
443 class: mailman.runners.digest.DigestRunner
444
445 [style.default]
446+
447+[archiver.rssarchive]
448+class: mailman.archiving.rssarchive.RssArchiver
449+enable: yes
450
451=== modified file 'src/mailman/rest/root.py'
452--- src/mailman/rest/root.py 2013-01-01 14:05:42 +0000
453+++ src/mailman/rest/root.py 2013-09-01 16:37:50 +0000
454@@ -42,6 +42,7 @@
455 from mailman.rest.preferences import ReadOnlyPreferences
456 from mailman.rest.templates import TemplateFinder
457 from mailman.rest.users import AUser, AllUsers
458+from mailman.rest.rss import RssFeed
459
460
461
462
463@@ -136,6 +137,14 @@
464 return AList(list_identifier), segments
465
466 @resource.child()
467+ def feeds(self, request, segments):
468+ """/<api>/feeds/<list>/
469+ """
470+ #return http.ok([], etag({u'segments': segments[0]}))
471+ list_identifier = segments.pop(0)
472+ return RssFeed(list_identifier), segments
473+
474+ @resource.child()
475 def members(self, request, segments):
476 """/<api>/members"""
477 if len(segments) == 0:
478
479=== added file 'src/mailman/rest/rss.py'
480--- src/mailman/rest/rss.py 1970-01-01 00:00:00 +0000
481+++ src/mailman/rest/rss.py 2013-09-01 16:37:50 +0000
482@@ -0,0 +1,72 @@
483+# Copyright (C) 2010-2013 by the Free Software Foundation, Inc.
484+#
485+# This file is part of GNU Mailman.
486+#
487+# GNU Mailman is free software: you can redistribute it and/or modify it under
488+# the terms of the GNU General Public License as published by the Free
489+# Software Foundation, either version 3 of the License, or (at your option)
490+# any later version.
491+#
492+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
493+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
494+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
495+# more details.
496+#
497+# You should have received a copy of the GNU General Public License along with
498+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
499+
500+"""REST for members."""
501+
502+from __future__ import absolute_import, unicode_literals
503+
504+__metaclass__ = type
505+__all__ = [
506+ 'RssAdmin',
507+ ]
508+
509+
510+from uuid import UUID
511+from operator import attrgetter
512+from restish import http, resource
513+from zope.component import getUtility
514+
515+from mailman.app.membership import delete_member
516+from mailman.interfaces.address import InvalidEmailAddressError
517+from mailman.interfaces.listmanager import IListManager, NoSuchListError
518+from mailman.interfaces.member import (
519+ AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError,
520+ NotAMemberError)
521+from mailman.interfaces.subscriptions import ISubscriptionService
522+from mailman.interfaces.user import UnverifiedAddressError
523+from mailman.interfaces.usermanager import IUserManager
524+from mailman.rest.helpers import (
525+ CollectionMixin, PATCH, etag, no_content, paginate, path_to)
526+from mailman.rest.preferences import Preferences, ReadOnlyPreferences
527+from mailman.rest.validator import (
528+ Validator, enum_validator, subscriber_validator)
529+from mailman.archiving.rssarchive import RssArchiver
530+
531+class RssFeed(resource.Resource):
532+ def __init__(self, list_identifier):
533+ # list-id is preferred, but for backward compatibility, fqdn_listname
534+ # is also accepted. If the string contains '@', treat it as the
535+ # latter.
536+ manager = getUtility(IListManager)
537+ if '@' in list_identifier:
538+ self._mlist = manager.get(list_identifier)
539+ else:
540+ self._mlist = manager.get_by_list_id(list_identifier)
541+
542+ @resource.GET()
543+ def getData(self, request):
544+ is_feed_enabled = RssArchiver.is_feed_enabled(self._mlist)
545+ size = RssArchiver.get_length_limit(self._mlist)
546+ return http.ok([], etag({u'enabled': is_feed_enabled, u'sizeLimit': size}))
547+
548+ @resource.POST()
549+ def setData(self, request):
550+ length_limit = request.POST.get('size_limit')
551+ is_enabled = request.POST.get('is_enabled')
552+ RssArchiver.set_length_limit(self._mlist, length_limit)
553+ RssArchiver.set_feed_enabled(self._mlist, is_enabled)
554+ return no_content()