diff --git a/RegistrationMigration/RegistrationMigration.py b/RegistrationMigration/RegistrationMigration.py new file mode 100644 index 0000000..4650c34 --- /dev/null +++ b/RegistrationMigration/RegistrationMigration.py @@ -0,0 +1,1483 @@ +#Role=Admin +# RegistrationMigration.py +# +# Sample script: converts an Involvement from "Join Involvement" registration to "Registration +# Form", carrying over its settings, questions, historical registrants, answers, and payments. +# +# IMPORTANT LIMITATION, please read before using this on real data: +# The old system stores a registrant's submitted answers as a single XML blob on their roster row +# (OrganizationMembers.OnlineRegData) -- there is exactly one such blob per involvement/person pairing, +# so each new submission overwrites the previous one. Therefore, the database only ever reflects the +# MOST RECENT submission. The new Registration Forms overcome this limitation, but cannot magically +# recreate the historical registration information that has been overwritten. +# +# Earlier submissions aren't necessarily gone, though. Since mid-2022 every enrollment also appends its +# answers to dbo.OnlineOPRRegQA (with the enrollment's date and transaction id), which is never +# overwritten -- this script uses that first, so each past enrollment becomes its own dated Registration +# with its own payment. For enrollments before that table existed: while a registration is being filled out (and +# briefly afterward), TouchPoint also saves it as its own row in dbo.RegistrationData -- one row per +# separate registration SESSION, not per person, and never overwritten or automatically deleted. This +# script mines that table for other completed sessions that included this person besides the one +# their roster row currently points to, and uses those to recover the answers (and, via +# Transaction.DatumId, the payment) for each earlier submission. This is a best-effort recovery, not +# a guarantee -- if an earlier RegistrationData row was manually deleted (there's an admin action for +# that), or predates this mechanism, its answers really are gone, and this script falls back to a +# "payment-only" Registration (the payment preserved and linked, but no recovered answers). +# +# QUESTIONS: In addition to migrating past registration data, this script attempts to migrate the questions themselves +# from the old registration system to the new. There isn't entirely a direct relationship between the old and the new, +# so always check to see if results are what you expect. +# +# Usage (paste into TouchPoint's admin "Special Content / PyScript" area and run as a report): +# ?orgid=1234 preview everything this would do; changes nothing +# ?orgid=1234&apply=1 do it: settings, questions, registrations, answers, payment links +# +# This does NOT flip the Involvement to Registration Form type -- do that separately with +# model.SetOrgRegistrationType once you've reviewed the result (see ValidateRegistrationMigration.sql). + +import json +import math +import re + +from System.Xml.Linq import XElement + +REGISTRATION_FORM_TYPE = 26 + +# --- Settings translation (Organizations.RegSettingXml -> Organizations.Reg* columns) ------------- + +# RegSettingXml is NOT flat: apart from , every setting sits inside a group element -- +# , , , or (see Settings.ReadXml in +# CmsData/RegistrationModels/ReadWriteXml.cs). Always read settings through setting_text / setting_flag, +# which take the group: a root-level lookup silently finds nothing. +# (group, element) -> (new Organizations column, conversion) +SETTINGS_FIELD_MAP = { + ("Fees", "Deposit"): ("RegDepositAmount", float), + ("Fees", "IncludeOtherFeesWithDeposit"): ("RegDepositIncludesOtherFees", lambda v: v.lower() == "true"), + ("Fees", "ApplyMaxToOtherFees"): ("RegMaxFeeAppliesToOtherFees", lambda v: v.lower() == "true"), + ("Options", "HoursBeforeTicketSalesEnd"): ("RegHoursBeforeTicketSalesEnd", int), + ("Confirmation", "Subject"): ("RegMessageConfirmationEmailSubject", str), + ("Instructions", "Sorry"): ("RegMessageClosed", str), + ("Instructions", "Thanks"): ("RegMessageConfirmationPage", str), + ("Options", "OnEnrollScript"): ("OnEnrollScript", str), +} + +# The old form never sent the confirmation email when its subject was exactly this. +NO_CONFIRMATION_EMAIL_SUBJECT = "DO NOT SEND" + + +def xml_text(root, name): + el = root.Element(name) + if el is None or not el.Value: + return None + return el.Value + + +def setting_text(root, group, name): + """A setting's text from RegSettingXml: text, or None if absent/empty.""" + if root is None: + return None + group_el = root.Element(group) + return xml_text(group_el, name) if group_el is not None else None + + +def setting_flag(root, group, name): + return (setting_text(root, group, name) or "").lower() == "true" + + +def find_account_code_id(value): + """Settings.AccountingCode holds the AccountCodes row's Id as text (the old editor stores the chosen + code's Id); older settings may instead hold the code itself, so fall back to matching on Code.""" + if not value: + return None + value = value.strip() + if value.isdigit(): + row = q.QuerySqlTop1("select Id from AccountCodes where Id = %d" % int(value)) + if row: + return row.Id + row = q.QuerySqlTop1("select Id from AccountCodes where Code = '%s'" % value.replace("'", "''")) + return row.Id if row else None + + +def parse_org_id(value): + # GroupToJoin in the legacy Settings is a stringified OrganizationId (see + # AddOrganizationController.Submit's oldsettings.GroupToJoin.ToInt2() usage); guard against it + # being blank or otherwise not a plain integer. + try: + return int(value) + except (TypeError, ValueError): + return None + + +def get_legacy_settings_xml(org_id): + row = q.QuerySqlTop1("select RegSettingXml from Organizations where OrganizationId = %d" % org_id) + if row is None: + raise Exception("no organization %d" % org_id) + return row.RegSettingXml + + +def to_number(text, default=0.0): + try: + return float(text) + except (TypeError, ValueError): + return default + + +def shorten(text, length=60): + text = " ".join((text or "").split()) + return text if len(text) <= length else text[:length - 3] + "..." + + +def child_elements(root, group, name): + group_el = root.Element(group) if root is not None else None + return list(group_el.Elements(name)) if group_el is not None else [] + + +def ask_types_present(root): + """The element names of this form's enabled AskItems (e.g. 'AskSuggestedFee', 'AskTickets').""" + ask_items = root.Element("AskItems") if root is not None else None + if ask_items is None: + return set() + present = set() + for ele in ask_items.Elements(): + disabled = ele.Attribute("Disabled") + if disabled is None or disabled.Value.lower() != "true": + present.add(ele.Name.LocalName) + return present + + +def unlayer_body(html): + """The Registration Form stores an email body as the JSON of its email designer, not as raw HTML; + a raw-HTML body is read as empty and sends a blank email. Text that already is designer JSON is kept.""" + if html.lstrip().startswith("{"): + try: + if "rawHtml" in json.loads(html): + return html + except ValueError: + pass + return json.dumps({"design": None, "rawHtml": html}) + + +# Replacement codes the new emails don't support, or render differently, so the preview can warn about +# any left in a migrated message (see RegistrationReplacements.cs and the email engine). +UNSUPPORTED_EMAIL_CODES = ("{division}", "{phone}", "{donation}", "{ev:") +DIFFERENT_EMAIL_CODES = ("{paylink}", "{details}") + + +def email_code_warnings(label, text, warnings): + lowered = (text or "").lower() + unsupported = [code for code in UNSUPPORTED_EMAIL_CODES if code in lowered] + different = [code for code in DIFFERENT_EMAIL_CODES if code in lowered] + if unsupported: + warnings.append("%s uses replacement code(s) the new form doesn't support: %s" % (label, ", ".join(unsupported))) + if different: + warnings.append("%s uses replacement code(s) that render differently in the new form: %s" % (label, ", ".join(different))) + + +def translate_fees(root, org, settings, warnings): + ask_types = ask_types_present(root) + base_fee = to_number(setting_text(root, "Fees", "Fee")) + extra_fee = to_number(setting_text(root, "Fees", "ExtraFee")) + max_fee = to_number(setting_text(root, "Fees", "MaximumFee")) + + age_groups = child_elements(root, "AgeGroups", "Group") + org_fees = child_elements(root, "OrgFees", "Fee") + + if age_groups: + warnings.append("Age-based fees (%d age group(s)) have no equivalent. The base fee was NOT migrated, or everyone " + "would be charged it; set the fee by hand." % len(age_groups)) + elif "AskSuggestedFee" in ask_types: + warnings.append("This form used a suggested-fee question, which replaced the base fee in the old form. The base fee " + "was NOT migrated (the new form would add it on top of the suggested fee).") + elif base_fee > 0 or extra_fee > 0: + # The old form added ExtraFee to the fee after LastDayBeforeExtra + 24h, or always if there was no date. + if extra_fee > 0 and org is not None and org.LastDayBeforeExtra is not None: + settings["RegFeePerPerson"] = base_fee + settings["RegFeeChange"] = extra_fee + settings["RegFeeChangeDate"] = org.LastDayBeforeExtra.AddHours(24) + else: + settings["RegFeePerPerson"] = base_fee + extra_fee + if extra_fee > 0: + warnings.append("The extra fee had no date, so the old form always charged it; it was added into the base fee.") + if "AskTickets" in ask_types and base_fee > 0: + warnings.append("The old form multiplied the fee by the number of tickets requested; the new form has no equivalent.") + + if max_fee > 0: + settings["RegMaxFee"] = max_fee # never 0: a zero maximum would zero every fee + + if org_fees: + entries = ", ".join("involvement %s: $%s" % (fee.Attribute("OrgId").Value if fee.Attribute("OrgId") is not None else "?", + fee.Attribute("Fee").Value if fee.Attribute("Fee") is not None else "?") + for fee in org_fees) + warnings.append("Involvement-based fees were NOT migrated (the old form REPLACED the fee with the lowest match; the " + "new form ADDS every match): %s" % entries) + + settings["RegDiscountCodes"] = setting_flag(root, "Fees", "ShowDiscountCodes") + + if setting_flag(root, "Fees", "ChargeServiceFee"): + warnings.append("This form charged its own service fee (%s%% + $%s per person/ticket). The new form's processing-fee " + "option works differently and was left off; adjust it by hand if you want it." % ( + setting_text(root, "Fees", "PercentagePerOrder") or "0", + setting_text(root, "Fees", "PerPersonOrTicket") or "0")) + + +def translate_access(root, org, settings, warnings): + """Who may register, and how: sign-in, member-only and involvement-membership rules.""" + require_sign_in = [] + if setting_flag(root, "Options", "DisallowAnonymous"): + require_sign_in.append("disallow anonymous") + + if setting_flag(root, "Options", "MemberOnly"): + settings["RegRequireMemberStatus"] = "10" # CmsData.Codes.MemberStatusCode.Member + require_sign_in.append("members only") + + validate_orgs = setting_text(root, "Options", "ValidateOrgs") + if validate_orgs: + ids = [int(i) for i in re.findall(r"-?\d+", validate_orgs)] + include = ",".join(str(i) for i in ids if i > 0) + exclude = ",".join(str(-i) for i in ids if i < 0) + if len(include) > 100 or len(exclude) > 100: + warnings.append("The involvement membership rules are too long for the new form's 100-character fields and " + "were NOT migrated: %s" % shorten(validate_orgs, 100)) + else: + if include: + settings["RegIncludeInvolvement"] = include + if exclude: + settings["RegExcludeInvolvement"] = exclude + require_sign_in.append("involvement membership rules") + + # The new form only applies the member-status and membership rules when sign-in is Required (1); + # otherwise anonymous (and, for the old default, no sign-in step) is Optional (2). + settings["RegSignIn"] = 1 if require_sign_in else 2 + if require_sign_in: + warnings.append("Sign-in is now REQUIRED, because the old form restricted registration by: %s. The new form only " + "applies those rules to people who are signed in." % ", ".join(require_sign_in)) + + +def translate_options(root, org, settings, warnings): + ask_types = ask_types_present(root) + + # One submission per person, or several. Deposit and limit only work with one per person. + multiple = setting_flag(root, "Options", "AllowReRegister") + settings["RegSubmissionType"] = 2 if multiple else 1 + settings["AllowOnlyOne"] = (setting_flag(root, "Options", "AllowOnlyOne") + or bool(org is not None and org.IsMissionTrip) + or "AskTickets" in ask_types) + if multiple: + if settings.get("RegLimit"): + warnings.append("Registrants may register more than once, so the new form doesn't support a registration limit; " + "RegLimit (%s) was dropped." % settings.pop("RegLimit")) + if settings.get("RegDepositAmount"): + warnings.append("Registrants may register more than once, so the new form doesn't support a deposit; " + "the deposit ($%s) was dropped." % settings.pop("RegDepositAmount")) + settings["RegDepositAmount"] = 0 + + # Old timeout is in milliseconds (default 180000 when absent); the new one is in minutes, and 1 is invalid. + timeout = setting_text(root, "Options", "TimeOut") + if timeout is not None: + milliseconds = int(to_number(timeout, -1)) + if milliseconds == 0: + settings["RegTimeout"] = 0 + elif milliseconds > 0: + settings["RegTimeout"] = max(2, int(math.ceil(milliseconds / 60000.0))) + + settings["RegJoinAsProspect"] = setting_flag(root, "Options", "AddAsProspect") + if settings["RegJoinAsProspect"]: + warnings.append("Add-as-prospect: the new form also adds registrants as prospects of the 'join another involvement' " + "target, where the old form always added them as members of it.") + + shell = setting_text(root, "Options", "ShellBs") + if shell: + settings["RegShellId"] = shell + + if org is not None and org.ClassFilled: + settings["RegistrationClosed"] = True + warnings.append("This involvement was marked 'class filled'; registration is closed in the new form.") + if org is not None and org.IsMissionTrip: + warnings.append("This is a mission-trip involvement; the new Registration Form has no mission-trip support.") + + +def translate_messages(root, org, settings, warnings): + """Confirmation and staff-notification emails. The new form stores each body as email-designer JSON and + sends a blank email if one is missing, so every Send flag is set deliberately.""" + subject = setting_text(root, "Confirmation", "Subject") + body = setting_text(root, "Confirmation", "Body") + notify_ids = re.findall(r"\d+", (org.NotifyIds if org is not None else None) or "") + + if settings.get("RegMessageConfirmationEmailSend") is False: + pass # old subject was the do-not-send marker + elif body: + settings["RegMessageConfirmationEmailBody"] = unlayer_body(body) + settings["RegMessageConfirmationEmailSend"] = True + if notify_ids: + settings["RegMessageConfirmationEmailFrom"] = int(notify_ids[0]) + else: + warnings.append("The confirmation email has no sender: the old settings named no staff to notify. Choose one " + "in the new settings before saving them there.") + else: + # The old form used the 'DefaultConfirmation' email template when a form had no text of its own. + default = q.QuerySqlTop1("select Title, Body from Content where Name = 'DefaultConfirmation'") + if default is not None and default.Body: + settings["RegMessageConfirmationEmailBody"] = unlayer_body(default.Body) + settings["RegMessageConfirmationEmailSend"] = True + if not subject and default.Title: + settings["RegMessageConfirmationEmailSubject"] = default.Title + if notify_ids: + settings["RegMessageConfirmationEmailFrom"] = int(notify_ids[0]) + warnings.append("The old form had no confirmation email text of its own, so the system's 'DefaultConfirmation' " + "email template was copied in.") + email_code_warnings("The default confirmation email", default.Body, warnings) + else: + settings["RegMessageConfirmationEmailSend"] = False + warnings.append("The old form had no confirmation email text and there is no 'DefaultConfirmation' template, " + "so the confirmation email was turned OFF rather than send a blank one.") + + # The old form always emailed the staff in NotifyIds, with fixed text. + if notify_ids: + settings["RegMessageNotificationEmailSend"] = True + settings["RegMessageNotificationEmailSubject"] = "New registration: {org}" + settings["RegMessageNotificationEmailBody"] = unlayer_body( + "

{name} has registered for {org}.

{details}

") + else: + settings["RegMessageNotificationEmailSend"] = False + + email_code_warnings("The confirmation email", "%s %s" % (subject or "", body or ""), warnings) + for label, text in (("The thank-you message", setting_text(root, "Instructions", "Thanks")), + ("The 'registration closed' message", setting_text(root, "Instructions", "Sorry"))): + lowered = (text or "").lower() + literal = [code for code in ("{org}", "{email}", "{url}") if code in lowered] + if literal: + warnings.append("%s uses replacement code(s) the new page shows literally: %s" % (label, ", ".join(literal))) + + +def warn_not_migrated(root, org, warnings): + """Old settings with no equivalent in the new form: say what's being left behind, with its value.""" + if setting_text(root, "Instructions", "Terms"): + warnings.append("Terms and conditions text was NOT migrated (the new form needs a Terms question backed by a " + "content file): %s" % shorten(setting_text(root, "Instructions", "Terms"))) + for name in ("Login", "Select", "Find", "LoggedIn", "Options", "Special", "Submit", "NoChildren", "AtLeastOneChild", + "MaxReached"): + text = setting_text(root, "Instructions", name) + if text: + warnings.append("The old '%s' step instructions were NOT migrated (the new form has one top message, " + "RegMessageTop): %s" % (name, shorten(text))) + for group, name, label in (("Options", "ConfirmationTrackingCode", "tracking code"), + ("Options", "FinishRegistrationButton", "custom finish-button text"), + ("Options", "SubmitButton", "custom submit-button text"), + ("Options", "SpecialScript", "special script")): + if setting_text(root, group, name): + warnings.append("The old %s was NOT migrated: %s" % (label, shorten(setting_text(root, group, name)))) + if org is not None and org.AddToSmallGroupScript: + warnings.append("The add-to-sub-group script was NOT migrated (the new form never runs it).") + if setting_text(root, "Reminder", "Body") or setting_text(root, "Reminder", "Subject"): + warnings.append("Reminder email settings stay in the old settings; nothing in the new form edits them.") + + +def get_legacy_org_row(org_id): + return q.QuerySqlTop1(""" + select [Limit] as LegacyLimit, LastDayBeforeExtra, NotifyIds, ClassFilled, IsMissionTrip, + AddToSmallGroupScript + from Organizations where OrganizationId = %d""" % org_id) + + +def translate_settings(org_id, settings_xml): + """Return (settings, warnings): settings is the {ColumnName: value} dict for the Registration Form + schema, translated from the legacy RegSettingXml plus a few legacy Organizations columns; warnings are + plain-text notes about old settings that were changed, dropped or can't be carried over. + NOTE: RegFeeType/RegFundId/RegMerchantId are Finance-role-gated by model.UpdateOrganization -- + this script never touches those (the old Settings.AccountingCode maps to RegAccountCodeId + instead, which isn't restricted), so no special handling is needed here. + The new settings screen validates some combinations that model.UpdateOrganization does not, so the + values produced here are kept consistent with those rules (timeout never 1, a fee change always has + a date, no deposit/limit with multiple submissions, ...).""" + org = get_legacy_org_row(org_id) + root = XElement.Parse(settings_xml) if settings_xml else None + settings = {} + warnings = [] + + if org is not None and org.LegacyLimit: + settings["RegLimit"] = org.LegacyLimit + + for (group, element), (column, convert) in SETTINGS_FIELD_MAP.items(): + value = setting_text(root, group, element) + if value is not None: + settings[column] = convert(value) + + if settings.get("RegMessageConfirmationEmailSubject") == NO_CONFIRMATION_EMAIL_SUBJECT: + # Don't copy the marker as a real subject; turn the email off instead. + del settings["RegMessageConfirmationEmailSubject"] + settings["RegMessageConfirmationEmailSend"] = False + + account_code_id = find_account_code_id(setting_text(root, "Fees", "AccountingCode")) + if account_code_id is not None: + settings["RegAccountCodeId"] = account_code_id + + other_org_id = parse_org_id(setting_text(root, "Options", "GroupToJoin")) + if other_org_id is not None: + settings["RegJoinOtherOrgId"] = other_org_id + + translate_fees(root, org, settings, warnings) + translate_options(root, org, settings, warnings) + translate_access(root, org, settings, warnings) + translate_messages(root, org, settings, warnings) + warn_not_migrated(root, org, warnings) + return settings, warnings + + +# --- Question translation (RegSettingXml AskItems -> RegQuestion rows) ----------------------------- + +# Not a real per-person question -- an org-level "answers not required" marker. Never appears in +# OnlineRegPersonModel.Enroll.cs's SaveAnswers switch, so it has no answer to recover either. +EXCLUDED_ASK_TYPES = ("AnswersNotRequired",) + +# A Label plus a list of Description/SmallGroup/Fee items. The "WithCondition"/"WithRedirect" +# variants add branching, which RegQuestionOption.SkipToId exists for in the new schema, but this +# script doesn't attempt to reconstruct that branching automatically -- their choices still convert, +# just as a flat (non-branching) list. +MULTI_CHOICE_ASK_TYPES = { + "AskCheckboxes": {"item_element": "CheckboxItem", "question_type": 3, "question_subtype": 0}, # MultipleChoice + "AskDropdown": {"item_element": "DropdownItem", "question_type": 6, "question_subtype": 0}, # DropDown + "AskMenu": {"item_element": "MenuItem", "question_type": 6, "question_subtype": 1}, # DropDown / Menu (counts per item) + "AskRadioButton": {"item_element": "RadioButtonItem", "question_type": 2, "question_subtype": 0}, # SingleChoice + "AskDropdownWithCondition": {"item_element": "DropdownWithConditionItem", "question_type": 6, "question_subtype": 0}, + "AskRadioCondition": {"item_element": "RadioConditionItem", "question_type": 2, "question_subtype": 0}, + # These two item element names follow the same {Type}Item naming convention as the others but + # weren't directly confirmed against source -- if a question of this type comes through with + # zero options, check CmsData/RegistrationModels/AskDropdownWithRedirect.cs / AskRadioRedirect.cs. + "AskDropdownWithRedirect": {"item_element": "DropdownWithRedirectItem", "question_type": 6, "question_subtype": 0}, + "AskRadioRedirect": {"item_element": "RadioRedirectItem", "question_type": 2, "question_subtype": 0}, +} + +TEXT_LIST_ASK_TYPES = ("AskText", "AskExtraQuestions") # both wrap a list of plain {Question} prompts + +# RegAnswerSaveAsEnum (Tps.Core.Entities.Registrations.Dto.RegSettingsEnum.cs): what the new form does +# with a person's answer beyond recording it. +SAVE_AS_ANSWER = 0 +SAVE_AS_SUBGROUP = 1 +SAVE_AS_EXTRA_VALUE = 2 + +# SmallGroup values the old forms reserved for rows that aren't real choices (a bolded heading with +# no checkbox, and a plain comment line) -- see AskCheckboxes.SmallGroups(). +NON_CHOICE_SMALL_GROUPS = ("nocheckbox", "comment") + +# Old choice-type questions put the person in a sub-group named for the choice, unless the question +# (its TargetExtraValue attribute) or the whole registration (Settings.TargetExtraValues) was set to +# write person extra values instead -- see OnlineRegPersonModel.Enroll.cs SaveCheckboxChoices et al. +# AskMenu has no extra-value branch in the old code: it always used sub-groups. +SUBGROUP_CHOICE_ASK_TYPES = ("AskCheckboxes", "AskDropdown", "AskRadioButton", "AskDropdownWithCondition", + "AskRadioCondition", "AskDropdownWithRedirect", "AskRadioRedirect", "AskMenu") +EXTRA_VALUE_CAPABLE_ASK_TYPES = ("AskText", "AskExtraQuestions", "AskYesNoQuestions") + tuple( + t for t in SUBGROUP_CHOICE_ASK_TYPES if t != "AskMenu") + + +def save_value_as_for(ele, ask_type, org_target_extra_values): + """Map the old per-question 'where does this answer go' behavior to a RegAnswerSaveAsEnum value.""" + target_attr = ele.Attribute("TargetExtraValue") + target_extra = org_target_extra_values or (target_attr is not None and target_attr.Value.lower() == "true") + if ask_type in EXTRA_VALUE_CAPABLE_ASK_TYPES and target_extra: + return SAVE_AS_EXTRA_VALUE + if ask_type in SUBGROUP_CHOICE_ASK_TYPES or ask_type == "AskYesNoQuestions": + return SAVE_AS_SUBGROUP + return SAVE_AS_ANSWER + +# AskDocuments' answers ARE covered by the @question-attributed DocumentUpload node (already in +# PRIOR_SUBMISSION_ANSWER_NODES / the OnlineRegQA view), so it's handled like a normal question here +# and needs no special-casing beyond its QuestionType. +SINGLE_ANSWER_ASK_TYPES = { + "AskDocuments": {"question_type": 7, "question_subtype": 0}, # FileUpload + "AskSuggestedFee": {"question_type": 11, "question_subtype": 10}, # Money / SuggestedFee -- no + # historical answer to recover; the old system never recorded one for this type. +} + +# Structural/instructional -- no per-person answer at all. +STRUCTURAL_ASK_TYPES = ("AskHeader", "AskInstruction", "AskInstructionPlain", "AskInstruction2", "AskDivider") + +# A single answer, or a handful of related fields joined into one readable string, read as plain +# XML element(s) (WriteXml's default reflection-based case) directly on the person's +# OnlineRegData/RegistrationData block -- not the @question-attributed pattern the other buckets use. +# Matched by ask type, not by question label. +# AskPassport's QUESTION is migrated but never its answers: its field list is empty on purpose, so no passport +# detail is ever read from the registration XML (and OnlineOPRRegQA's encrypted rows are skipped too). +# ask_type -> (question_type, question_subtype, [(xml_property, display_label_or_None), ...]) +# These values are recorded as registration answers only. They are never copied onto the person's own +# record (cell phone, SMS opt-in, grade level, etc.): that would overwrite present state with whatever +# the person said at registration time, and this migration preserves history without changing the present. +SIMPLE_ELEMENT_ASK_TYPES = { + "AskRequest": (1, 0, [("request", None)]), # Text + "AskTickets": (1, 0, [("ntickets", None)]), # Text + "AskPledge": (11, 8, [("Pledge", None)]), # Money / Pledge + "AskAllergies": (1, 3, [("medical", None)]), # Text / Allergies + "AskTylenolEtc": (3, 4, [("tylenol", "Tylenol"), ("advil", "Advil"), + ("robitussin", "Robitussin"), ("maalox", "Maalox")]), # MultipleChoice / Medication + "AskEmContact": (8, 0, [("emcontact", "Contact"), ("emphone", "Phone")]), # EmergencyContact + "AskInsurance": (13, 0, [("insurance", "Carrier"), ("policy", "Policy")]), # Insurance + "AskDoctor": (14, 0, [("doctor", "Doctor"), ("docphone", "Phone")]), # Doctor + "AskParents": (12, 0, [("mname", "Mother"), ("fname", "Father")]), # Parents + "AskPassport": (9, 0, []), # Passport -- question only, no answers + "AskCoaching": (2, 0, [("coaching", None)]), # SingleChoice (Yes/No) + "AskChurch": (3, 0, [("memberus", "Member here"), ("nochurch", "Not a member anywhere"), + ("otherchurch", "Attends another church")]), # MultipleChoice + "AskCellPhone": (1, 0, [("cellPhone", None)]), # Text + "AskSMS": (2, 0, [("sms", None)]), # SingleChoice (Yes/No) + "AskGradeOptions": (6, 11, [("gradeoption", None)]), # DropDown / Grade + "AskSize": (6, 2, [("shirtsize", None)]), # DropDown / ShirtSize +} + + +def default_label(ask_type): + """A readable question label for an ask type whose old XML has no