SEBA-419 allow legacy to be specified on a per-model basis;
Deprecate "legacy" in favor of "custom_python"

Change-Id: I44625ff18ee7796996ae25ad700e58a0b36e7398
diff --git a/lib/xos-genx/xos-genx-tests/test_generator.py b/lib/xos-genx/xos-genx-tests/test_generator.py
index 8212d51..b3710cd 100644
--- a/lib/xos-genx/xos-genx-tests/test_generator.py
+++ b/lib/xos-genx/xos-genx-tests/test_generator.py
@@ -45,6 +45,12 @@
 FILTERTEST_XPROTO = os.path.abspath(
     os.path.dirname(os.path.realpath(__file__)) + "/xproto/filtertest.xproto"
 )
+CUSTOM_TEST1_XPROTO = os.path.abspath(
+    os.path.dirname(os.path.realpath(__file__)) + "/xproto/custom_test1.xproto"
+)
+CUSTOM_TEST2_XPROTO = os.path.abspath(
+    os.path.dirname(os.path.realpath(__file__)) + "/xproto/custom_test2.xproto"
+)
 SKIP_DJANGO_XPROTO = os.path.abspath(
     os.path.dirname(os.path.realpath(__file__)) + "/xproto/skip_django.xproto"
 )
@@ -114,25 +120,25 @@
         output = XOSProcessor.process(args)
 
         # xosmodel has custom header attic
-        self.assertIn("from xosmodel_header import *", output["XOSModel"])
-        self.assertIn("class XOSModel(XOSBase):", output["XOSModel"])
+        self.assertIn("from core.models.xosbase import *", output["XOSModel"])
+        self.assertIn("class XOSModel_decl(XOSBase):", output["XOSModel"])
 
         # vrouter port use the default header
-        self.assertIn("header import *", output["VRouterPort"])
-        self.assertIn("class VRouterPort(XOSBase):", output["VRouterPort"])
+        self.assertIn("from core.models.xosbase import *", output["VRouterPort"])
+        self.assertIn("class VRouterPort_decl(XOSBase):", output["VRouterPort"])
 
         # verify files
         xosmodel = OUTPUT_DIR + "/xosmodel.py"
         self.assertTrue(os.path.isfile(xosmodel))
         xmf = open(xosmodel).read()
-        self.assertIn("from xosmodel_header import *", xmf)
-        self.assertIn("class XOSModel(XOSBase):", xmf)
+        self.assertIn("from core.models.xosbase import *", xmf)
+        self.assertIn("class XOSModel_decl(XOSBase):", xmf)
 
         vrouterport = OUTPUT_DIR + "/vrouterport.py"
         self.assertTrue(os.path.isfile(vrouterport))
         vrpf = open(vrouterport).read()
-        self.assertIn("header import *", vrpf)
-        self.assertIn("class VRouterPort(XOSBase):", vrpf)
+        self.assertIn("from core.models.xosbase import *", vrpf)
+        self.assertIn("class VRouterPort_decl(XOSBase):", vrpf)
 
     def test_django_with_base(self):
         args = XOSProcessorArgs(
@@ -149,14 +155,14 @@
         xosmodel = OUTPUT_DIR + "/xosmodel.py"
         self.assertTrue(os.path.isfile(xosmodel))
         xmf = open(xosmodel).read()
-        self.assertIn("from xosmodel_header import *", xmf)
-        self.assertIn("class XOSModel(XOSBase):", xmf)
+        self.assertIn("from core.models.xosbase import *", xmf)
+        self.assertIn("class XOSModel_decl(XOSBase):", xmf)
 
         xosbase = OUTPUT_DIR + "/xosbase.py"
         self.assertTrue(os.path.isfile(xosbase))
         xbf = open(xosbase).read()
-        self.assertIn("header import *", xbf)
-        self.assertIn("class XOSBase(models.Model, PlModelMixIn):", xbf)
+        self.assertIn("from core.models.xosbase import *", xbf)
+        self.assertIn("class XOSBase_decl(models.Model, PlModelMixIn):", xbf)
 
     def test_write_multiple_files(self):
         """
@@ -312,6 +318,74 @@
 
         self.assertEqual(output, "Model1,Model2,")
 
+    def test_django_custom_test1(self):
+        args = XOSProcessorArgs(
+            files=[CUSTOM_TEST1_XPROTO, BASE_XPROTO],
+            target="django.xtarget",
+            attic=TEST_ATTICS,
+            output=OUTPUT_DIR,
+            dest_extension="py",
+            write_to_file="model",
+        )
+        output = XOSProcessor.process(args)
+
+        # verify files
+        xosmodel = OUTPUT_DIR + "/xosmodel_decl.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel_decl(XOSBase):", xmf)
+
+        xosmodel = OUTPUT_DIR + "/xosmodel2_decl.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel2_decl(XOSBase):", xmf)
+
+    def test_django_custom_test2(self):
+        args = XOSProcessorArgs(
+            files=[CUSTOM_TEST2_XPROTO, BASE_XPROTO],
+            target="django.xtarget",
+            attic=TEST_ATTICS,
+            output=OUTPUT_DIR,
+            dest_extension="py",
+            write_to_file="model",
+        )
+        output = XOSProcessor.process(args)
+
+        # verify files
+        xosmodel = OUTPUT_DIR + "/xosmodel_decl.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel_decl(XOSBase):", xmf)
+        self.assertNotIn("class XOSModel(XOSModel_decl):", xmf)
+
+        xosmodel = OUTPUT_DIR + "/xosmodel2.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel2_decl(XOSBase):", xmf)
+        self.assertIn("class XOSModel2(XOSModel2_decl):", xmf)
+
+    def test_service_custom_test1(self):
+        args = XOSProcessorArgs(
+            files=[CUSTOM_TEST1_XPROTO, BASE_XPROTO],
+            target="service.xtarget",
+            attic=TEST_ATTICS,
+            output=OUTPUT_DIR,
+            dest_extension="py",
+            write_to_file="target",
+        )
+        output = XOSProcessor.process(args)
+
+        # verify files
+        xosmodel = OUTPUT_DIR + "/models_decl.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel_decl(XOSBase_decl):", xmf)
+
+        xosmodel = OUTPUT_DIR + "/models_decl.py"
+        self.assertTrue(os.path.isfile(xosmodel))
+        xmf = open(xosmodel).read()
+        self.assertIn("class XOSModel2_decl(XOSBase_decl):", xmf)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/lib/xos-genx/xos-genx-tests/test_jinja2_base.py b/lib/xos-genx/xos-genx-tests/test_jinja2_base.py
index 3b988e5..b2cbdef 100644
--- a/lib/xos-genx/xos-genx-tests/test_jinja2_base.py
+++ b/lib/xos-genx/xos-genx-tests/test_jinja2_base.py
@@ -16,6 +16,7 @@
 from __future__ import absolute_import
 import unittest
 from xosgenx.jinja2_extensions.base import *
+from jinja2.runtime import Undefined
 
 
 # Several of the base functions require a Field object.
@@ -79,6 +80,24 @@
             xproto_singularize(_field("sheep", singular="turtle")), "turtle"
         )
 
+    def test_xproto_first_non_empty(self):
+        self.assertEqual(xproto_first_non_empty(["a"]), "a")
+        self.assertEqual(xproto_first_non_empty([None,"a"]), "a")
+        self.assertEqual(xproto_first_non_empty([None]), None)
+        self.assertEqual(xproto_first_non_empty([]), None)
+        self.assertEqual(xproto_first_non_empty([False, True]), False)
+        self.assertEqual(xproto_first_non_empty([None, "Foo", True]), "Foo")
+        self.assertEqual(xproto_first_non_empty(["", "Foo", True]), "Foo")
+        self.assertEqual(xproto_first_non_empty([Undefined(), "Foo", True]), "Foo")
+
+    def test_list_evaluates_true(self):
+        self.assertTrue(xproto_list_evaluates_true([True]))
+        self.assertTrue(xproto_list_evaluates_true(["True"]))
+        self.assertTrue(xproto_list_evaluates_true(['"True"']))
+        self.assertFalse(xproto_list_evaluates_true([False, True]))
+        self.assertFalse(xproto_list_evaluates_true([]))
+        self.assertFalse(xproto_list_evaluates_true([False]))
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/lib/xos-genx/xos-genx-tests/xproto/custom_test1.xproto b/lib/xos-genx/xos-genx-tests/xproto/custom_test1.xproto
new file mode 100644
index 0000000..753500f
--- /dev/null
+++ b/lib/xos-genx/xos-genx-tests/xproto/custom_test1.xproto
@@ -0,0 +1,13 @@
+// custom_python at the root level should override both models
+
+option custom_python = True;
+
+message XOSModel (XOSBase) {
+     required string name = 1 [max_length = 200, content_type = "stripped", blank = False, help_text = "Help Name", null = False, db_index = False];
+     required string files = 2 [max_length = 1024, content_type = "stripped", blank = False, help_text = "Help Files", null = False, db_index = False];
+}
+
+message XOSModel2 (XOSBase) {
+     required string name = 1 [max_length = 200, content_type = "stripped", blank = False, help_text = "Help Name", null = False, db_index = False];
+     required string files = 2 [max_length = 1024, content_type = "stripped", blank = False, help_text = "Help Files", null = False, db_index = False];
+}
\ No newline at end of file
diff --git a/lib/xos-genx/xos-genx-tests/xproto/custom_test2.xproto b/lib/xos-genx/xos-genx-tests/xproto/custom_test2.xproto
new file mode 100644
index 0000000..f3877ee
--- /dev/null
+++ b/lib/xos-genx/xos-genx-tests/xproto/custom_test2.xproto
@@ -0,0 +1,12 @@
+// custom_python at the model level should override only one model
+
+message XOSModel (XOSBase) {
+     option custom_python = True;
+     required string name = 1 [max_length = 200, content_type = "stripped", blank = False, help_text = "Help Name", null = False, db_index = False];
+     required string files = 2 [max_length = 1024, content_type = "stripped", blank = False, help_text = "Help Files", null = False, db_index = False];
+}
+
+message XOSModel2 (XOSBase) {
+     required string name = 1 [max_length = 200, content_type = "stripped", blank = False, help_text = "Help Name", null = False, db_index = False];
+     required string files = 2 [max_length = 1024, content_type = "stripped", blank = False, help_text = "Help Files", null = False, db_index = False];
+}
\ No newline at end of file
diff --git a/lib/xos-genx/xosgenx/generator.py b/lib/xos-genx/xosgenx/generator.py
index d1dc99a..278a309 100644
--- a/lib/xos-genx/xosgenx/generator.py
+++ b/lib/xos-genx/xosgenx/generator.py
@@ -174,20 +174,6 @@
             print("Saved: %s" % file_name)
 
     @staticmethod
-    def _write_file_per_model(rendered, dir, suffix, quiet):
-        for m in rendered:
-            file_name = "%s/%s%s" % (dir, m.lower(), suffix)
-            if not rendered[m]:
-                if not quiet:
-                    print("Not saving %s as it is empty" % file_name)
-            else:
-                file = open(file_name, "w")
-                file.write(rendered[m])
-                file.close()
-                if not quiet:
-                    print("Saved: %s" % file_name)
-
-    @staticmethod
     def _write_split_target(rendered, dir, quiet):
 
         lines = rendered.splitlines()
@@ -348,16 +334,17 @@
         template = os_template_env.get_template(template_name)
 
         if args.output is not None and args.write_to_file == "model":
+            # Handle the case where each model is written to a separate python file.
             rendered = {}
+
             for i, model in enumerate(v.models):
-                models = {}
-                models[model] = v.models[model]
+                model_dict = v.models[model]
                 messages = [XOSProcessor._find_message_by_model_name(v.messages, model)]
 
                 rendered[model] = template.render(
                     {
                         "proto": {
-                            "message_table": models,
+                            "message_table": {model: model_dict},
                             "messages": messages,
                             "policies": v.policies,
                             "message_names": [m["name"] for m in v.messages],
@@ -366,14 +353,28 @@
                         "options": v.options,
                     }
                 )
-            if str(v.options.get("legacy", "false")).strip('"').lower() == "true":
-                suffix = "_decl." + args.dest_extension
-            else:
-                suffix = "." + args.dest_extension
-            XOSProcessor._write_file_per_model(
-                rendered, args.output, suffix, args.quiet
-            )
+                if not rendered[model]:
+                    print("Not saving model %s as it is empty" % model, file=sys.stderr)
+                else:
+                    legacy = jinja2_extensions.base.xproto_list_evaluates_true(
+                        [model_dict.get("options", {}).get("custom_python", None),
+                         model_dict.get("options", {}).get("legacy", None),
+                         v.options.get("custom_python", None),
+                         v.options.get("legacy", None)])
+
+                    if legacy:
+                        file_name = "%s/%s_decl.%s" % (args.output, model.lower(), args.dest_extension)
+                    else:
+                        file_name = "%s/%s.%s" % (args.output, model.lower(), args.dest_extension)
+
+                    file = open(file_name, "w")
+                    file.write(rendered[model])
+                    file.close()
+                    if not args.quiet:
+                        print("Saved: %s" % file_name, file=sys.stderr)
         else:
+            # Handle the case where all models are written to the same python file.
+
             rendered = template.render(
                 {
                     "proto": {
diff --git a/lib/xos-genx/xosgenx/jinja2_extensions/base.py b/lib/xos-genx/xosgenx/jinja2_extensions/base.py
index fd4808e..c8f5bfe 100644
--- a/lib/xos-genx/xosgenx/jinja2_extensions/base.py
+++ b/lib/xos-genx/xosgenx/jinja2_extensions/base.py
@@ -15,6 +15,7 @@
 from __future__ import absolute_import, print_function
 import pdb
 import re
+from jinja2.runtime import Undefined
 from inflect import engine as inflect_engine_class
 
 inflect_engine = inflect_engine_class()
@@ -94,9 +95,12 @@
 
 
 def xproto_first_non_empty(lst):
-    for l in lst:
-        if l:
-            return l
+    # Returns the first non-empty element in the list. Empty is interpreted to be either
+    # None or the empty string or an instance of jinja2 Undefined(). The value False and the
+    # string "False" are not considered empty, but are values.
+    for item in lst:
+        if (item is not None) and (item != "") and (not isinstance(item, Undefined)):
+            return item
 
 
 def xproto_api_type(field):
@@ -481,3 +485,10 @@
     if x is True or (x == "True") or (x == '"True"'):
         return True
     return False
+
+
+def xproto_list_evaluates_true(lst):
+    # Returns True if the first non-empty item in the list is interpreted
+    # as True.
+    x = xproto_first_non_empty(lst)
+    return xproto_is_true(x)
diff --git a/lib/xos-genx/xosgenx/targets/django.xtarget b/lib/xos-genx/xosgenx/targets/django.xtarget
index 1c06de3..081fb5f 100644
--- a/lib/xos-genx/xosgenx/targets/django.xtarget
+++ b/lib/xos-genx/xosgenx/targets/django.xtarget
@@ -1,22 +1,16 @@
-{%- if options.legacy =='"True"' -%}
+{% for m in proto.messages %}{% if not m.options.skip_django -%}
+{%- if xproto_list_evaluates_true([m.options.custom_python, m.options.legacy, options.custom_python, options.legacy]) -%}
 {%- set legacy_tag = '_decl' -%}
 {%- set legacy = True -%}
 {%- else -%}
 {%- set legacy_tag = '' -%}
 {%- set legacy = False -%}
 {%- endif -%}
-{% for m in proto.messages %}{% if not m.options.skip_django -%}
-{% if legacy %}
-{# handle models that use custom headers rather than deriving from xosbase #}
 {% if m.options.custom_header %}
 from {{ m.options.custom_header|replace('"','') }} import *
 {% else %}
 from core.models.xosbase import *
 {% endif %}
-{% else %}
-{% if file_exists(xproto_base_name(m.name)|lower+'_header.py') -%}from {{xproto_base_name(m.name)|lower }}_header import *{%- else -%}from header import *{% endif %}
-{% if file_exists(xproto_base_name(m.name)|lower+'_top.py') -%}{{ include_file(xproto_base_name(m.name)|lower+'_top.py') }} {% endif %}
-{% endif %}
 
 {%- for l in m.links %}
 
@@ -41,7 +35,7 @@
 {{ xproto_fol_to_python_validator(policy, proto.policies[policy], m, error) }}
 {% endfor %}
 
-class {{ m.name }}{{ legacy_tag }}{{ xproto_base_def(m.name, m.bases) }}:
+class {{ m.name }}_decl{{ xproto_base_def(m.name, m.bases) }}:
   plural_name = "{{ xproto_pluralize(m) }}"
 
   {%- set feedback_state_fields = xproto_optioned_fields_to_list(xproto_base_fields(m, proto.message_table) + m.fields, 'feedback_state', 'True')  %}
@@ -105,7 +99,7 @@
       {% for policy,error in xproto_validations(m.options) %}
       policy_{{policy}}_validator(self, None)
       {% endfor %}
-      super({{ m.name }}{{ legacy_tag }}, self).save(*args, **kwds)
+      super({{ m.name }}_decl, self).save(*args, **kwds)
 
   def can_access(self, ctx):
       {% if m.policy %}
@@ -117,6 +111,14 @@
       {% endif %}
       
   {% endif %}
-    
+
+{# To maintain compatibility with migrations, we need to ensure that both the _decl and the non-_decl model #}
+{# exist. So we automatically create all models as _decl, and then add these trivial stubs #}
+{% if not legacy %}
+class {{ m.name }}({{ m. name }}_decl):
+    class Meta:
+        proxy = True
+{% endif %}
+
 {% if file_exists(xproto_base_name(m.name)|lower+'_bottom.py') -%}{{ include_file(xproto_base_name(m.name)|lower+'_bottom.py') }}{% endif %}
 {% endif %}{% endfor %}
diff --git a/lib/xos-genx/xosgenx/targets/service.xtarget b/lib/xos-genx/xosgenx/targets/service.xtarget
index 4c47fab..2061219 100644
--- a/lib/xos-genx/xosgenx/targets/service.xtarget
+++ b/lib/xos-genx/xosgenx/targets/service.xtarget
@@ -1,18 +1,6 @@
-{% if options.legacy =='"True"' -%}
-{% set legacy_tag = '_decl' %}
-{% set legacy = True %}
 from core.models.xosbase import *
 from core.models import ServiceInstance
-{% else %}
-{% set legacy = False %}
-{% set legacy_tag = '' %}
-{% if file_exists('../header.py') or file_exists('header.py')-%}from header import *
-{% else %}
-from core.models.xosbase import *
-from core.models import ServiceInstance
-{% endif %}
-{% endif %}
-
+{% set ns=namespace(any_legacy_tag = '') %}
 {% for m in proto.messages %}
 {% if file_exists(m.name|lower+'_header.py') -%}from {{m.name|lower }}_header import *{% endif %}
 {% if file_exists(m.name|lower+'_top.py') -%}{{ include_file(m.name|lower+'_top.py') }} {% endif %}
@@ -37,8 +25,22 @@
 
 {% endfor %}
 
+{# Compute any_legacy_tag by looking to see if any model in the service has legacy sete #}
 {% for m in proto.messages %}
-class {{ m.name }}{{ legacy_tag }}{{ xproto_base_def(m.name, m.bases, legacy_tag, proto.message_names) }}:
+{%- if xproto_list_evaluates_true([m.options.custom_python, m.options.legacy, options.custom_python, options.legacy]) -%}
+{% set ns.any_legacy_tag = '_decl' %}
+{% endif %}
+{% endfor %}
+
+{% for m in proto.messages %}
+{%- if xproto_list_evaluates_true([m.options.custom_python, m.options.legacy, options.custom_python, options.legacy]) -%}
+{% set legacy_tag = '_decl' %}
+{% set legacy = True %}
+{% else %}
+{% set legacy = False %}
+{% set legacy_tag = '' %}
+{% endif %}
+class {{ m.name }}{{ ns.any_legacy_tag }}{{ xproto_base_def(m.name, m.bases, legacy_tag, proto.message_names) }}:
   plural_name = "{{ xproto_pluralize(m) }}"
 
   {%- set feedback_state_fields = xproto_optioned_fields_to_list(xproto_base_fields(m, proto.message_table) + m.fields, 'feedback_state', 'True')  %}
@@ -95,7 +97,7 @@
           self.full_clean()
 
       if not base_save_in_attic:
-          super({{ m.name }}{{ legacy_tag }}, self).save(*args, **kwds)
+          super({{ m.name }}{{ ns.any_legacy_tag }}, self).save(*args, **kwds)
   
   def can_access(self, ctx):
       {% if m.policy %}
@@ -106,6 +108,14 @@
       return verdict,"xos_default_policy"
       {% endif %}
 
+{# To maintain compatibility with migrations, we need to ensure that both the _decl and the non-_decl model #}
+{# exist. So we automatically create all models as _decl, and then add these trivial stubs #}
+{% if (not legacy) and (ns.any_legacy_tag) %}
+class {{ m.name }}({{ m. name }}{{ ns.any_legacy_tag }}):
+    class Meta:
+        proxy = True
+{% endif %}
+
 {% if file_exists(m.name|lower+'_bottom.py') -%}{{ include_file(m.name|lower+'_bottom.py') }}{% endif %} 
 {% endfor %}
-+++ models{{ legacy_tag }}.py
++++ models{{ ns.any_legacy_tag }}.py