diff --git a/opentech/apply/funds/management/commands/migrate_community_lab_applications.py b/opentech/apply/funds/management/commands/migrate_community_lab_applications.py new file mode 100644 index 0000000000000000000000000000000000000000..abd41a346a35c8fcadc95e43784fdad674f8a7b7 --- /dev/null +++ b/opentech/apply/funds/management/commands/migrate_community_lab_applications.py @@ -0,0 +1,294 @@ +import argparse +import json + +from datetime import datetime, timezone + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.utils import IntegrityError +from django_fsm import FSMField + +from opentech.apply.categories.models import Category, Option +from opentech.apply.categories.categories_seed import CATEGORIES +from opentech.apply.funds.models import ApplicationSubmission, FundType, Round, RoundForm +from opentech.apply.funds.workflow import INITIAL_STATE + +User = get_user_model() + +STREAMFIELD_MAP = { + "title": { + "id": "title", + "type": "direct", + }, + "field_application_name": { + "id": "full_name", + "type": "value", + # If no Drupal value key is specified, we default to 'value' + "key": "safe_value", + }, + "field_application_mail": { + "id": "email", + "type": "value", + "key": "email", + }, + "field_application_event_date": { + "id": "8c74af9c-6cc7-4558-9d72-0f2c9a87f22b", + "type": "value", + "key": "value", + }, + "field_application_amount": { + "id": "value", + "type": "value", + }, + "field_application_describe": { + "id": "fe488e12-b5f4-491a-9ca9-d7aff0993884", + "type": "value", + "key": "safe_value", + }, + "field_application_who": { + "id": "e7a0bc56-ad5d-4be7-9709-eb823a0e6e3d", + "type": "value", + "key": "safe_value", + }, + "field_application_objective_text": { + "id": "43d52051-27f2-4d30-abf1-173a31f15072", + "type": "value", + "key": "safe_value", + }, + "field_application_strategy": { + "id": "4e1f46ad-12d7-40c3-a1e8-0793bb327961", + "type": "value", + "key": "safe_value", + }, + "field_application_how": { + "id": "e33e1415-6832-4ef3-8a10-ae6d3aef61c8", + "type": "value", + "key": "safe_value", + }, + "field_application_collaboration": { + "id": "812792a3-edc5-4521-b5c7-e9c697122325", + "type": "value", + "key": "safe_value", + }, + "field_application_diverse": { + "id": "c367cae6-9fde-40fc-8c99-7ca2117bda6a", + "type": "value", + "key": "safe_value", + }, + "field_application_outreach": { + "id": "14ef1b53-ef85-4756-a13e-19d3c3be7d85", + "type": "value", + "key": "safe_value", + }, + "field_application_needs": { + "id": "eb6474e1-2f69-4f69-9a9c-edf13c25455c", + "type": "value", + "key": "safe_value", + }, + "field_application_budget": { + "id": "de631da99f904f5f9c67e3a6e182f7c6", + "type": "value", + "key": "safe_value", + }, + "field_application_cod": { + "id": "4948cc0fd1d142eeb81dd10784fba0f2", + "type": "boolean", + }, + "field_application_otf_mission": { + "id": "9b20aa6384d54f64b1fb846efed89a41", + "type": "boolean", + }, + "field_application_otf_tos": { + "id": "b4a2f762f61c402aa8d22b58b3201263", + "type": "boolean", + }, + "field_application_otf_represent": { + "id": "9409408f0cee4c97ac0517838eacdd9f", + "type": "boolean", + }, + "field_application_otf_license": { + "id": "e0e6990db8744781afe9d42a105b8ff4", + "type": "boolean", + }, + "field_application_otf_complete": { + "id": "966cd67f04a34c16b4e5892d4cd1e175", + "type": "boolean", + }, + "field_application_otf_deadline": { + "id": "d5b982f829dd4ee4aab3eb5349e6b077", + "type": "boolean", + }, + "field_application_otf_list": { + "id": "4a4feb4e6e5445bd83b42e9f39ca833c", + "type": "boolean", + }, + "field_application_otf_newsletter": { + "id": "e011bd48613648d48263997f71656bfc", + "type": "boolean", + }, + + "field_concept_upload": { + "id": "8c4f9cf13d624b64ab70e6cd342921f5", + "type": "file", + # TODO: finish mapping + }, +} + +FUND = FundType.objects.get(title='Community lab') +ROUND = Round.objects.get(title='Community lab open round') +FORM = RoundForm.objects.get(round=ROUND) + +# Monkey patch the status field so it is no longer protected +patched_status_field = FSMField(default=INITIAL_STATE, protected=False) +setattr(ApplicationSubmission, 'status', patched_status_field) + + +class Command(BaseCommand): + help = "Community lab migration script. Requires a source JSON file." + data = [] + terms = {} + + def add_arguments(self, parser): + parser.add_argument('source', type=argparse.FileType('r'), help='Migration source JSON file') + + @transaction.atomic + def handle(self, *args, **options): + # Prepare the list of categories. + for item in CATEGORIES: + category, _ = Category.objects.get_or_create(name=item['category']) + option, _ = Option.objects.get_or_create(value=item['name'], category=category) + self.terms[item['tid']] = option + + with options['source'] as json_data: + self.data = json.load(json_data) + + for id in self.data: + self.process(id) + + def process(self, id): + node = self.data[id] + + try: + submission = ApplicationSubmission.objects.get(drupal_id=node['nid']) + except ApplicationSubmission.DoesNotExist: + submission = ApplicationSubmission(drupal_id=node['nid']) + + # TODO timezone? + submission.submit_time = datetime.fromtimestamp(int(node['created']), timezone.utc) + submission.user = self.get_user(node['uid']) + + submission.page = FUND + submission.round = ROUND + submission.form_fields = FORM.form.form_fields + + submission.status = self.get_workflow_state(node) + + form_data = { + 'skip_account_creation_notification': True, + } + + for field in node: + if field in STREAMFIELD_MAP: + try: + id = STREAMFIELD_MAP[field]['id'] + form_data[id] = self.get_field_value(field, node) + except TypeError: + pass + + submission.form_data = form_data + + try: + submission.save() + self.stdout.write(f"Processed \"{node['title']}\" ({node['nid']})") + except IntegrityError: + pass + + def get_user(self, uid): + try: + return User.objects.get(drupal_id=uid) + finally: + return None + + def get_field_value(self, field, node): + """ + Handles the following formats: + field: {(safe_)value: VALUE} + field: {target_id: ID} -- Drupal ForeignKey. Reference to other node or user entities. + field: {tid: ID} -- or term ID. fk to Categories + field: [] + field: [{value|target_id|tid: VALUE},] + """ + mapping = STREAMFIELD_MAP[field] + mapping_type = mapping['type'] + key = mapping.get('key', 'value') + source_value = node[field] + value = None + + if mapping_type == "direct": + value = source_value + elif mapping_type == 'value': + value = self.nl2br(source_value[key]) if source_value else '' + elif mapping_type == 'map' and 'map' in 'mapping': + value = mapping['map'].get(source_value[key]) + elif mapping_type == 'address' and 'map' in mapping: + try: + value_map = mapping['map'] + value = {} + for item in value_map: + value[value_map[item]] = source_value[item] + except TypeError: + value = {} + elif mapping_type == 'boolean': + value = source_value[key] == '1' if source_value else False + elif mapping_type == 'category': + if not source_value: + value = [] + else: + if isinstance(source_value, dict): + option = self.get_referenced_term(source_value[key]) + value = [option] if option else [] + else: + value = [] + for item in source_value: + option = self.get_referenced_term(item[key]) + if option: + value.append(option) + elif mapping_type == 'file': + # TODO finish mapping. Requires access to the files. + value = {} + + return value + + def get_referenced_term(self, tid): + try: + term = self.terms[tid] + return term.id + except KeyError: + return None + + def get_referenced_node(self, nid): + pass + + def get_workflow_state(self, node): + """ + workbench_moderation: {'current': {'state': STATE, 'timestamp': TS}} + """ + states = { + "draft": "", + "published": "in_discussion", + "in_discussion": "in_discussion", + "council_review": "internal_review", + "ready_for_reply": "post_review_discussion", + "contract_review": "post_review_discussion", + "in_contract": "accepted", + "invited_for_proposal": "accepted", + "dropped_concept_note": "rejected", + "dropped": "rejected", + "dropped_without_review": "rejected" + } + + return states.get(node['workbench_moderation']['current']['state'], "in_discussion") + + def nl2br(self, value): + return value.replace('\r\n', '<br>\n')