#!/usr/bin/env python
"""Clone a discussion, either within or between databases."""
from __future__ import print_function
# Put something like this in the crontab:
# 10 3 * * * cd /var/www/assembl ; ./venv/bin/python assembl/scripts/clone_discussion.py -n assembldemosandbox -d -p system.Authenticated+admin_discussion -p system.Authenticated+add_post -p system.Authenticated+add_extract -p system.Authenticated+edit_extract -p system.Authenticated+add_idea -p system.Authenticated+edit_idea -p system.Authenticated+edit_synthesis -p system.Authenticated+vote -p system.Authenticated+read local.ini assembldemo
from builtins import next
from builtins import str
import itertools
from collections import defaultdict
import argparse
from inspect import isabstract, signature, isclass
import logging.config
import traceback
from functools import partial
import pdb
from os.path import abspath
from pyramid.paster import get_appsettings, bootstrap
from sqlalchemy.orm import (
class_mapper, undefer, with_polymorphic, sessionmaker)
from sqlalchemy.orm.properties import ColumnProperty
import transaction
from sqlalchemy.sql.visitors import ClauseVisitor
from sqlalchemy.sql.expression import and_
from sqlalchemy import inspect
from assembl.auth import SYSTEM_ROLES, ASSEMBL_PERMISSIONS
from assembl.lib.config import set_config, get_config
from assembl.lib.sqla import (
configure_engine, get_session_maker, make_session_maker, get_metadata,
session_maker_is_initialized)
from assembl.lib.zmqlib import configure_zmq
from assembl.lib.model_watcher import configure_model_watcher
from assembl.lib.raven_client import setup_raven, capture_exception
def find_or_create_object_by_keys(db, keys, obj, columns=None, joins=None):
args = {key: getattr(obj, key) for key in keys}
eq = db.query(obj.__class__).filter_by(**args)
if joins:
for rel_name in joins:
joined_obj = getattr(obj, rel_name)
assert joined_obj
corresponding = find_or_create_object(joined_obj)
assert corresponding
eq = eq.filter_by(rel_name=corresponding)
args[rel_name] = corresponding
eq = eq.first()
if eq is None:
if columns is not None:
args.update({key: getattr(obj, key) for key in columns})
if "session" in signature(obj.__class__.__init__).parameters:
args["session"] = db
eq = obj.__class__(**args)
db.add(eq)
return eq
fn_for_classes = None
user_refs = None
special_extra_tests = None
def init_key_for_classes(db):
global fn_for_classes, user_refs, special_extra_tests
from assembl.models import (
AgentProfile, User, Permission, Role, Webpage, Action, LocalUserRole,
IdentityProvider, EmailAccount, WebLinkAccount, Preferences, URIRefDb,
NotificationSubscription, DiscussionPerUserNamespacedKeyValue,
IdeaLocalUserRole, PublicationFlow, PublicationState,
PublicationTransition, MultiCriterionVotingWidget)
fn_for_classes = {
AgentProfile: partial(find_or_create_agent_profile, db),
User: partial(find_or_create_agent_profile, db),
URIRefDb: partial(find_or_create_urlref, db),
Webpage: partial(find_or_create_object_by_keys, db, ['url']),
Permission: partial(find_or_create_object_by_keys, db, ['name']),
Preferences: partial(find_or_create_object_by_keys, db, ['name']),
Role: partial(find_or_create_object_by_keys, db, ['name']),
PublicationFlow: partial(find_or_create_object_by_keys, db, ['name']),
PublicationState: partial(find_or_create_object_by_keys, db, ['name'], joins=['flow']),
PublicationTransition: partial(find_or_create_object_by_keys, db, ['name'], joins=['flow']),
# SocialAuthAccount: partial(find_or_create_object_by_keys, db, ['provider_id', 'uid']),
IdentityProvider: partial(find_or_create_object_by_keys, db, ['provider_type', 'name']),
# email_ci?
EmailAccount: partial(find_or_create_object_by_keys, db, ['email'], columns=['preferred']),
WebLinkAccount: partial(find_or_create_object_by_keys, db, ['user_link']),
}
# these are objects that refer to users and should not be copied
user_refs = {
Action: 'actor',
NotificationSubscription: 'user',
LocalUserRole: 'user',
IdeaLocalUserRole: 'user',
DiscussionPerUserNamespacedKeyValue: 'user',
}
special_extra_tests = {
Preferences: lambda ob: ob.name == Preferences.BASE_PREFS_NAME
}
def find_or_create_object(ob):
global fn_for_classes
assert ob.__class__ in fn_for_classes
return fn_for_classes[ob.__class__](ob)
def is_special_class(ob):
global fn_for_classes, special_extra_tests
if ob.__class__ in fn_for_classes:
if ob.__class__ in special_extra_tests:
return special_extra_tests[ob.__class__](ob)
return True
if ob.__class__.__name__ == 'UserTemplate':
return False
assert not isinstance(ob, tuple(fn_for_classes.keys())),\
"Missing subclass: " + ob.__class__
return False
def find_or_create_provider_account(db, account):
from assembl.models import SocialAuthAccount
assert isinstance(account, SocialAuthAccount)
# Note: need a similar one for SourceSpecificAccount
identity_provider = find_or_create_object(account.identity_provider)
args = {
"identity_provider": identity_provider,
"uid": account.uid,
"username": account.username,
"provider_domain": account.provider_domain
}
to_account = db.query(SocialAuthAccount).filter_by(**args).first()
if to_account is None:
for k in ['extra_data', 'picture_url']:
args[k] = getattr(account, k)
to_account = account.__class__(**args)
db.add(to_account)
return to_account
def find_or_create_urlref(db, urlref):
from assembl.models import URIRefDb
assert isinstance(urlref, URIRefDb)
to_urlref = db.query(URIRefDb).filter_by(val=urlref.val).first()
if to_urlref is None:
to_urlref = URIRefDb(val=urlref.val)
db.add(to_urlref)
return to_urlref
def find_or_create_agent_profile(db, profile):
from assembl.models import (
AgentProfile, SocialAuthAccount, User)
assert isinstance(profile, AgentProfile)
accounts = []
profiles = set()
for account in profile.accounts:
if isinstance(account, SocialAuthAccount):
eq = find_or_create_provider_account(db, account)
else:
eq = find_or_create_object(account)
if eq.profile:
profiles.add(eq.profile)
accounts.append(eq)
if not profiles:
cols = ['name', 'description']
# if isinstance(profile, User):
# cols += ["preferred_email", "timezone"]
new_profile = AgentProfile(**{k: getattr(profile, k) for k in cols})
db.add(new_profile)
else:
user_profiles = {p for p in profiles if isinstance(p, User)}
if user_profiles:
new_profile = user_profiles.pop()
profiles.remove(new_profile)
else:
new_profile = profiles.pop()
while profiles:
new_profile.merge(profiles.pop())
for account in accounts:
if account.profile is None:
account.profile = new_profile
db.add(account)
return new_profile
def find_or_create_user_template(db, template):
pass
def print_path(path):
print([(x, y.__class__.__name__, y.id) for (x, y) in path])
def prefetch(session, discussion_id):
from assembl.lib.sqla import class_registry
from assembl.models import DiscussionBoundBase
for name, cls in class_registry.items():
if (isclass(cls) and issubclass(cls, DiscussionBoundBase)
and not isabstract(cls)):
mapper = class_mapper(cls)
undefers = [undefer(attr.key) for attr in mapper.iterate_properties
if getattr(attr, 'deferred', False)]
conditions = cls.get_discussion_conditions(discussion_id)
session.query(with_polymorphic(cls, "*")).filter(
and_(*conditions)).options(*undefers).all()
def recursive_fetch(ob, visited=None):
# Not used
visited = visited or {ob}
mapper = class_mapper(ob.__class__)
for attr in mapper.iterate_properties:
if getattr(attr, 'deferred', False):
getattr(ob, attr.key)
for reln in mapper.relationships:
subobs = getattr(ob, reln.key)
if not subobs:
continue
if not isinstance(subobs, list):
subobs = [subobs]
for subob in subobs:
if subob in visited:
continue
visited.add(subob)
if is_special_class(subob):
continue
recursive_fetch(subob, visited)
class_info = {}
TRAVERSE_ONE_TO_MANY = {
"LangString": ("entries",),
}
TREAT_AS_NON_NULLABLE = {
"Content": ("subject", "body"),
}
def get_mapper_info(mapper):
from assembl.lib.history_mixin import TombstonableMixin
from assembl.models import LangStringEntry
if mapper not in class_info:
pk_keys_cols = set([c for c in mapper.primary_key])
direct_reln = {r for r in mapper.relationships
if r.direction.name == 'MANYTOONE'
and r.viewonly == False
and not r.local_remote_pairs[0][0].primary_key}
direct_reln_cols = set(itertools.chain(
*[r.local_columns for r in direct_reln]))
avoid_columns = pk_keys_cols.union(direct_reln_cols)
copy_col_props = {a for a in mapper.iterate_properties
if isinstance(a, ColumnProperty)
and not avoid_columns.intersection(set(a.columns))}
if issubclass(mapper.class_, TombstonableMixin):
# It might have been excluded by a relation.
copy_col_props.add(mapper._props['tombstone_date'])
non_nullable_reln = {
r for r in direct_reln
if any([not c.nullable for c in r.local_columns])}
treat_as_non_nullable = []
for cls in mapper.class_.mro():
relns = TREAT_AS_NON_NULLABLE.get(cls.__name__, ())
if relns:
treat_as_non_nullable.extend(relns)
if treat_as_non_nullable:
for name in treat_as_non_nullable:
non_nullable_reln.add(mapper.relationships[name])
nullable_relns = direct_reln - non_nullable_reln
one_to_many_relns = TRAVERSE_ONE_TO_MANY.get(
mapper.class_.__name__, ())
if one_to_many_relns:
nullable_relns.update(
{mapper.relationships[n] for n in one_to_many_relns})
class_info[mapper] = (
direct_reln, copy_col_props, nullable_relns, non_nullable_reln)
return class_info[mapper]
def assign_dict(values, r, subob):
assert r.direction.name == 'MANYTOONE'
values[r.key] = subob
for col in r.local_columns:
if col.foreign_keys:
fkcol = next(iter(col.foreign_keys)).column
k = next(iter(r.local_columns))
values[col.key] = getattr(subob, fkcol.key)
return
print("assign_dict: missing foreign key?")
def assign_ob(ob, r, subob):
if r.direction.name != 'MANYTOONE':
if r.mapper != ob.__class__.__mapper__:
"DISCARDING", r
# Handled by the reverse connection
return
for col in r.local_columns:
if col.foreign_keys:
fkcol = next(iter(col.foreign_keys)).column
k = next(iter(r.local_columns))
setattr(ob, col.key, getattr(subob, fkcol.key))
return
setattr(ob, r.key, subob)
print("assign_ob: missing foreign key?")
[docs]class JoinColumnsVisitor(ClauseVisitor):
def __init__(self, cls, query, classes_by_table):
super(JoinColumnsVisitor, self).__init__()
self.base_class = cls
self.classes = {cls}
self.column = None
self.query = query
self.classes_by_table = classes_by_table
self.missing = []
def is_known_class(self, cls):
if cls in self.classes:
return True
for other_cls in self.classes:
if issubclass(cls, other_cls):
self.classes.add(cls)
return True
elif issubclass(other_cls, cls):
self.classes.add(cls)
return True
def base_class_for_table(self, table):
classes = self.classes_by_table[table]
cls = classes[0]
for other in classes[1:]:
if issubclass(cls, other):
cls = other
return cls
def process_column(self, column):
from assembl.lib.history_mixin import TombstonableMixin
source_cls = self.base_class_for_table(column.table)
classes = [self.base_class_for_table(foreign_key.column.table)
for foreign_key in getattr(column, 'foreign_keys', ())]
if not classes:
return self.is_known_class(source_cls)
dest_cls = classes[0]
classes.append(source_cls)
if all((self.is_known_class(c) for c in classes)):
return True
if all((not self.is_known_class(c) for c in classes)):
return False
orm_relns = [r for r in source_cls.__mapper__.relationships
if column in r.local_columns and r.secondary is None]
if len(orm_relns) > 1 and (
issubclass(dest_cls, TombstonableMixin) or
issubclass(source_cls, TombstonableMixin)):
orm_relns = [
r for r in orm_relns
if "tombstone_date" not in str(r.primaryjoin)]
if len(orm_relns) != 1:
print("wrong orm_relns for %s.%s : %s" % (
column.table.name, column.name, str(orm_relns)))
rattrib = getattr(source_cls, orm_relns[0].key)
self.query = self.query.join(dest_cls, rattrib)
self.classes.add(source_cls)
self.classes.add(dest_cls)
return True
def final_query(self):
while len(self.missing):
missing = []
for column in self.missing:
if not self.process_column(column):
missing.append(column)
if len(missing) == len(self.missing):
break
self.missing = missing
assert not self.missing
return self.query
def visit_column(self, column):
if not self.process_column(column):
self.missing.append(column)
def delete_discussion(session, discussion_id):
from assembl.models import (
Base, Discussion, DiscussionBoundBase, Preferences, LangStringEntry)
# delete anything related first
classes = DiscussionBoundBase._decl_class_registry.values()
classes_by_table = defaultdict(list)
for cls in classes:
if isclass(cls):
classes_by_table[getattr(cls, '__table__', None)].append(cls)
# Only direct subclass of abstract
def is_concrete_class(cls):
if isabstract(cls):
return False
for (i, cls) in enumerate(cls.mro()):
if not i:
continue
if not issubclass(cls, Base):
continue
return isabstract(cls)
concrete_classes = set([cls for cls in itertools.chain(
*list(classes_by_table.values()))
if issubclass(cls, DiscussionBoundBase) and
is_concrete_class(cls)])
concrete_classes.add(Preferences)
concrete_classes.add(LangStringEntry)
tables = DiscussionBoundBase.metadata.sorted_tables
# Special case for preferences
discussion = session.query(Discussion).get(discussion_id)
if discussion.preferences:
session.delete(discussion.preferences)
# tables.append(Preferences.__table__)
tables.reverse()
for table in tables:
if table not in classes_by_table:
continue
for cls in classes_by_table[table]:
if cls not in concrete_classes:
continue
print('deleting', cls.__name__)
query = session.query(cls.id)
if hasattr(cls, "get_discussion_conditions"):
conds = cls.get_discussion_conditions(discussion_id)
else:
continue
assert conds
cond = and_(*conds)
v = JoinColumnsVisitor(cls, query, classes_by_table)
v.traverse(cond)
query = v.final_query().filter(cond)
if query.count():
print("*" * 20, "Not all deleted!")
ids = query.all()
for subcls in cls.mro():
if getattr(subcls, '__tablename__', None):
session.query(subcls).filter(
subcls.id.in_(ids)).delete(False)
session.flush()
def clone_discussion(
from_session, discussion_id, to_session=None, new_slug=None):
from assembl.models import (
DiscussionBoundBase, Discussion, Post, User, Preferences, HistoryMixin,
BaseIdeaWidget, TombstonableMixin, MultiCriterionVotingWidget)
global user_refs
discussion = from_session.query(Discussion).get(discussion_id)
assert discussion
prefetch(from_session, discussion_id)
changes = defaultdict(dict)
if to_session is None:
to_session = from_session
changes[discussion]['slug'] = new_slug or (discussion.slug + "_copy")
else:
changes[discussion]['slug'] = new_slug or discussion.slug
copies_of = {}
history_new_base_ids = {}
copies = set()
in_process = set()
promises = defaultdict(list)
def resolve_promises(ob, copy):
if ob in promises:
for (o, reln) in promises[ob]:
print('fullfilling', o.__class__, o.id)
assign_ob(o, reln, copy)
del promises[ob]
def recursive_clone(ob, path):
if ob in copies_of:
return copies_of[ob]
if ob in copies:
return ob
if ob in in_process:
print("in process", ob.__class__, ob.id)
return None
if is_special_class(ob):
if from_session == to_session:
copy = ob
else:
copy = find_or_create_object(ob)
to_session.flush()
assert copy is not None
copies_of[ob] = copy
return copy
if isinstance(ob, DiscussionBoundBase):
assert discussion_id == ob.get_discussion_id()
print("recursive_clone", end=' ')
print_path(path)
mapper = class_mapper(ob.__class__)
(direct_reln, copy_col_props, nullable_relns, non_nullable_reln
) = get_mapper_info(mapper)
values = {r.key: getattr(ob, r.key, None) for r in copy_col_props}
print("->", ob.__class__, ob.id)
in_process.add(ob)
for r in non_nullable_reln:
subob = getattr(ob, r.key, None)
# Special case for tombstones
if (subob is None and isinstance(ob, TombstonableMixin) and
ob.is_tombstone):
key = next(iter(r._calculated_foreign_keys)).key
subob_id = getattr(ob, key)
if subob_id:
target_cls = r._dependency_processor.mapper.class_
subob = from_session.query(target_cls).get(subob_id)
# TODO: handle the case of an action on a tomstoned idea
assert subob is not None
assert subob not in in_process
print('recurse ^0', r.key, subob.id)
result = recursive_clone(subob, path + [(r.key, subob)])
assert result is not None
assert result.id
print('result', result.__class__, result.id)
assign_dict(values, r, result)
local_promises = {}
for r in nullable_relns:
subob = getattr(ob, r.key, None)
if subob is not None:
if isinstance(subob, list):
local_promises[r] = subob
elif subob in copies_of:
assign_dict(values, r, copies_of[subob])
else:
local_promises[r] = subob
values.update(changes[ob])
if isinstance(ob, Discussion):
values['table_of_contents'] = None
values['root_idea'] = None
values['next_synthesis'] = None
values['preferences'] = None
elif isinstance(ob, Preferences):
# we got here because we're not the default pref
target_discussion = copies_of[discussion]
values['name'] = 'discussion_' + target_discussion.slug
values['cascade_preferences'] = ob.cascade_preferences
elif isinstance(ob, tuple(user_refs.keys())):
# WHAT was I trying to do here?
for cls in ob.__class__.mro():
if cls in user_refs:
user = values.get(user_refs[cls])
if not isinstance(user, User):
return ob
break
if "session" in signature(ob.__class__.__init__).parameters:
values["session"] = to_session
if isinstance(ob, HistoryMixin):
values['base_id'] = history_new_base_ids.get(
(ob.__class__, ob.base_id), None)
copy = ob.__class__(**values)
copy._before_insert() # set the base_id
if ob.is_tombstone:
copy.id = None
copy._before_insert() # reset a new id
else:
copy.id = copy.base_id
history_new_base_ids[(ob.__class__, ob.base_id)] = copy.base_id
else:
copy = ob.__class__(**values)
# Remove objects created by constructor side-effects
if isinstance(copy, BaseIdeaWidget):
if copy.base_idea_link:
to_session.expunge(copy.base_idea_link)
copy.base_idea_link = None
while copy.idea_links:
copy.idea_links.pop()
# Now add the object
to_session.add(copy)
to_session.flush()
print("<-", ob.__class__, ob.id, copy.id)
copies_of[ob] = copy
copies.add(copy)
in_process.remove(ob)
resolve_promises(ob, copy)
for reln, subob in local_promises.items():
if isinstance(subob, list):
for subobel in subob:
print('recurse 0', reln.key, subobel.id)
result = recursive_clone(subobel, path + [(reln.key, subobel)])
if result is None: # in process
print("promising", subobel.__class__, subobel.id, reln.key)
promises[subobel].append((copy, reln))
else:
print("resolving promise", reln.key, result.__class__, result.id)
assign_ob(copy, reln, result)
elif subob in in_process:
print("promising", subob.__class__, subob.id, reln.key)
promises[subob].append((copy, reln))
else:
print('recurse 0', reln.key, subob.id)
result = recursive_clone(subob, path + [(reln.key, subob)])
if result is None: # in process
print("promising", subob.__class__, subob.id, reln.key)
promises[subob].append((copy, reln))
else:
print("resolving promise", reln.key, result.__class__, result.id)
assign_ob(copy, reln, result)
to_session.flush()
return copy
treating = set()
def stage_2_rec_clone(ob, path):
if ob in treating:
return
if is_special_class(ob):
if from_session == to_session:
copy = ob
else:
copy = find_or_create_object(ob)
to_session.flush()
assert copy is not None
copies_of[ob] = copy
return copy
print("stage_2_rec_clone", end=' ')
if isinstance(ob, DiscussionBoundBase):
assert discussion_id == ob.get_discussion_id()
print_path(path)
treating.add(ob)
if ob in copies_of:
copy = copies_of[ob]
elif ob in copies:
copy = ob
else:
copy = recursive_clone(ob, path)
resolve_promises(ob, copy)
treating.add(copy)
mapper = class_mapper(ob.__class__)
(
direct_reln, copy_col_props, nullable_relns, non_nullable_reln
) = get_mapper_info(mapper)
for r in mapper.relationships:
if r in direct_reln:
continue
subobs = getattr(ob, r.key)
if subobs is None:
continue
if not isinstance(subobs, list):
subobs = [subobs]
for subob in subobs:
stage_2_rec_clone(subob, path + [(r.key, subob)])
if isinstance(copy, MultiCriterionVotingWidget):
# TODO similar for tokens?
uri_equivs = {}
uri_qnum = {}
for vs in copy.vote_specifications:
j = vs.settings_json
old_id = j.get('@id', None)
uri = vs.uri()
j['@id'] = uri
vs.settings_json = j
if old_id:
uri_equivs[old_id] = uri
uri_qnum[vs.question_id] = uri
j = copy.settings_json
for qnum, item in enumerate(j['items']):
for spec in item['vote_specifications']:
old_id = spec["@id"]
spec["@id"] = uri_equivs.get(old_id, uri_qnum.get(qnum, old_id))
copy.settings_json = j
path = [('', discussion)]
copy = recursive_clone(discussion, path)
stage_2_rec_clone(discussion, path)
to_session.flush()
for p in to_session.query(Post).filter_by(
discussion=copy, parent_id=None).all():
p._set_ancestry('')
to_session.flush()
return copy
def engine_from_settings(config, full_config=False):
settings = get_appsettings(config, 'idealoom')
db_schema = settings['db_schema']
set_config(settings, True)
session = None
if full_config:
env = bootstrap(config)
configure_zmq(settings['changes_socket'], False)
configure_model_watcher(env['registry'], 'idealoom')
logging.config.fileConfig(config)
session = get_session_maker()
metadata = get_metadata()
else:
session = make_session_maker(zope_tr=True)
import assembl.models
from assembl.lib.sqla import class_registry
engine = configure_engine(settings, session_maker=session)
metadata = get_metadata()
metadata.bind = engine
session = sessionmaker(engine)()
return (metadata, session)
def copy_discussion(source_config, dest_config, source_slug, dest_slug,
delete=False, debug=False, permissions=None):
if (session_maker_is_initialized() and abspath(source_config) == get_config()["__file__"]):
# not running from script
dest_session = get_session_maker()()
dest_metadata = get_metadata()
else:
dest_metadata, dest_session = engine_from_settings(
dest_config, True)
dest_tables = dest_metadata.sorted_tables
if source_config != dest_config:
from assembl.lib.sqla import _session_maker
temp = _session_maker
assert temp == dest_session
source_metadata, source_session = engine_from_settings(
source_config, False)
source_tables_by_name = {
table.name: table.tometadata(source_metadata, source_metadata.schema)
for table in dest_tables
}
_session_maker = dest_session
else:
source_metadata, source_session = dest_metadata, dest_session
try:
init_key_for_classes(dest_session)
from assembl.models import Discussion
discussion = source_session.query(Discussion).filter_by(
slug=source_slug).one()
assert discussion, "No discussion named " + source_slug
permissions = [x.split('+') for x in permissions or ()]
for (role, permission) in permissions:
assert role in SYSTEM_ROLES
assert permission in ASSEMBL_PERMISSIONS
existing = dest_session.query(Discussion).filter_by(slug=dest_slug).first()
if existing:
if delete:
print("deleting", dest_slug)
with transaction.manager:
delete_discussion(dest_session, existing.id)
else:
print("Discussion", dest_slug, end=' ')
print("already exists! Add -d to delete it.")
exit(0)
from assembl.models import Role, Permission, DiscussionPermission
with dest_session.no_autoflush:
copy = clone_discussion(
source_session, discussion.id, dest_session, dest_slug)
for (role, permission) in permissions:
role = dest_session.query(Role).filter_by(name=role).one()
permission = dest_session.query(Permission).filter_by(
name=permission).one()
# assumption: Not already defined.
dest_session.add(DiscussionPermission(
discussion=copy, role=role, permission=permission))
except Exception:
traceback.print_exc()
if debug:
pdb.post_mortem()
capture_exception()
return dest_session
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
"configuration",
help="configuration file with destination database configuration")
parser.add_argument("-n", "--new_name", help="slug of new discussion")
parser.add_argument("-d", "--delete", action="store_true", default=False,
help="delete discussion copy if exists")
parser.add_argument("--debug", action="store_true", default=False,
help="enter pdb on failure")
parser.add_argument(
"-s", "--source_db_configuration",
help="""configuration file with source database configuration, if distinct.
Be aware that ODBC.ini settings are distinct.""")
parser.add_argument("discussion", help="original discussion slug")
parser.add_argument("-p", "--permissions", action="append", default=[],
help="Add a role+permission pair to the copy "
"(eg system.Authenticated+admin_discussion)")
args = parser.parse_args()
new_name = args.new_name or (
args.discussion + ("" if args.source_db_configuration else "_copy"))
with transaction.manager:
session = copy_discussion(
args.source_db_configuration or args.configuration,
args.configuration,
args.discussion, new_name,
args.delete, args.debug, args.permissions)