Uaktualnienie django-south.
authorMarek Stępniowski <marek@stepniowski.com>
Sat, 21 Feb 2009 18:46:29 +0000 (19:46 +0100)
committerMarek Stępniowski <marek@stepniowski.com>
Sat, 21 Feb 2009 18:46:29 +0000 (19:46 +0100)
Wyświetlanie linków do Lektury.Gazeta.pl na stronach lektur i w katalogu.

22 files changed:
apps/catalogue/migrations/0004_add_gazeta_links.py [new file with mode: 0644]
apps/catalogue/models.py
apps/south/__init__.py
apps/south/db/generic.py
apps/south/db/mysql.py
apps/south/db/postgresql_psycopg2.py
apps/south/db/sql_server/__init__.py [new file with mode: 0644]
apps/south/db/sql_server/pyodbc.py [new file with mode: 0644]
apps/south/db/sqlite3.py
apps/south/install/README [deleted file]
apps/south/install/setup.py [deleted file]
apps/south/management/commands/migrate.py
apps/south/management/commands/startmigration.py
apps/south/management/commands/syncdb.py
apps/south/management/commands/test.py [new file with mode: 0644]
apps/south/migration.py
apps/south/setup.py [new file with mode: 0755]
apps/south/tests/db.py
apps/south/tests/fakeapp/migrations/0003_alter_spam.py [new file with mode: 0644]
apps/south/tests/logic.py
wolnelektury/templates/catalogue/book_detail.html
wolnelektury/templates/catalogue/tagged_object_list.html

diff --git a/apps/catalogue/migrations/0004_add_gazeta_links.py b/apps/catalogue/migrations/0004_add_gazeta_links.py
new file mode 100644 (file)
index 0000000..d750a67
--- /dev/null
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+from south.db import db
+from django.db import models
+
+
+class Migration:    
+    def forwards(self):
+        db.add_column('catalogue_tag', 'gazeta_link', models.CharField(blank=True,  max_length=240))
+        db.add_column('catalogue_book', 'gazeta_link', models.CharField(blank=True,  max_length=240))
+    
+    def backwards(self):
+        db.delete_column('catalogue_tag', 'gazeta_link')
+        db.delete_column('catalogue_book', 'gazeta_link')
index 0cd19cc..5bfe6cb 100644 (file)
@@ -47,6 +47,7 @@ class Tag(TagBase):
     
     user = models.ForeignKey(User, blank=True, null=True)
     book_count = models.IntegerField(_('book count'), default=0, blank=False, null=False)
     
     user = models.ForeignKey(User, blank=True, null=True)
     book_count = models.IntegerField(_('book count'), default=0, blank=False, null=False)
+    gazeta_link = models.CharField(blank=True,  max_length=240)
     
     def has_description(self):
         return len(self.description) > 0
     
     def has_description(self):
         return len(self.description) > 0
@@ -88,6 +89,7 @@ class Book(models.Model):
     _short_html = models.TextField(_('short HTML'), editable=False)
     parent_number = models.IntegerField(_('parent number'), default=0)
     extra_info = JSONField(_('extra information'))
     _short_html = models.TextField(_('short HTML'), editable=False)
     parent_number = models.IntegerField(_('parent number'), default=0)
     extra_info = JSONField(_('extra information'))
+    gazeta_link = models.CharField(blank=True,  max_length=240)
     
     # Formats
     xml_file = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
     
     # Formats
     xml_file = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
index e797d85..3e5972e 100644 (file)
@@ -2,5 +2,5 @@
 South - Useable migrations for Django apps
 """
 
 South - Useable migrations for Django apps
 """
 
-__version__ = "0.3"
-__authors__ = ["Andrew Godwin <andrew@aeracode.org>", "Andy McCurdy <andy@andymccurdy.com>"]
\ No newline at end of file
+__version__ = "0.4"
+__authors__ = ["Andrew Godwin <andrew@aeracode.org>", "Andy McCurdy <andy@andymccurdy.com>"]
index 09dde03..4a5b512 100644 (file)
@@ -1,10 +1,23 @@
 
 
+import datetime
 from django.core.management.color import no_style
 from django.db import connection, transaction, models
 from django.db.backends.util import truncate_name
 from django.core.management.color import no_style
 from django.db import connection, transaction, models
 from django.db.backends.util import truncate_name
+from django.db.models.fields import NOT_PROVIDED
 from django.dispatch import dispatcher
 from django.conf import settings
 
 from django.dispatch import dispatcher
 from django.conf import settings
 
+
+def alias(attrname):
+    """
+    Returns a function which calls 'attrname' - for function aliasing.
+    We can't just use foo = bar, as this breaks subclassing.
+    """
+    def func(self, *args, **kwds):
+        return getattr(self, attrname)(*args, **kwds)
+    return func
+
+
 class DatabaseOperations(object):
 
     """
 class DatabaseOperations(object):
 
     """
@@ -12,10 +25,14 @@ class DatabaseOperations(object):
     Some of this code comes from Django Evolution.
     """
 
     Some of this code comes from Django Evolution.
     """
 
+    # We assume the generic DB can handle DDL transactions. MySQL wil change this.
+    has_ddl_transactions = True
+
     def __init__(self):
         self.debug = False
         self.deferred_sql = []
     def __init__(self):
         self.debug = False
         self.deferred_sql = []
-
+        self.dry_run = False
+        self.pending_create_signals = []
 
     def execute(self, sql, params=[]):
         """
 
     def execute(self, sql, params=[]):
         """
@@ -25,31 +42,50 @@ class DatabaseOperations(object):
         cursor = connection.cursor()
         if self.debug:
             print "   = %s" % sql, params
         cursor = connection.cursor()
         if self.debug:
             print "   = %s" % sql, params
+
+        if self.dry_run:
+            return []
+
         cursor.execute(sql, params)
         try:
             return cursor.fetchall()
         except:
             return []
         cursor.execute(sql, params)
         try:
             return cursor.fetchall()
         except:
             return []
-            
-            
+
+
     def add_deferred_sql(self, sql):
         """
         Add a SQL statement to the deferred list, that won't be executed until
         this instance's execute_deferred_sql method is run.
         """
         self.deferred_sql.append(sql)
     def add_deferred_sql(self, sql):
         """
         Add a SQL statement to the deferred list, that won't be executed until
         this instance's execute_deferred_sql method is run.
         """
         self.deferred_sql.append(sql)
-        
-        
+
+
     def execute_deferred_sql(self):
         """
         Executes all deferred SQL, resetting the deferred_sql list
         """
         for sql in self.deferred_sql:
             self.execute(sql)
     def execute_deferred_sql(self):
         """
         Executes all deferred SQL, resetting the deferred_sql list
         """
         for sql in self.deferred_sql:
             self.execute(sql)
-            
+
         self.deferred_sql = []
 
 
         self.deferred_sql = []
 
 
+    def clear_deferred_sql(self):
+        """
+        Resets the deferred_sql list to empty.
+        """
+        self.deferred_sql = []
+    
+    
+    def clear_run_data(self):
+        """
+        Resets variables to how they should be before a run. Used for dry runs.
+        """
+        self.clear_deferred_sql()
+        self.pending_create_signals = []
+
+
     def create_table(self, table_name, fields):
         """
         Creates the table 'table_name'. 'fields' is a tuple of fields,
     def create_table(self, table_name, fields):
         """
         Creates the table 'table_name'. 'fields' is a tuple of fields,
@@ -57,14 +93,22 @@ class DatabaseOperations(object):
         django.db.models.fields.Field object
         """
         qn = connection.ops.quote_name
         django.db.models.fields.Field object
         """
         qn = connection.ops.quote_name
+
+        # allow fields to be a dictionary
+        # removed for now - philosophical reasons (this is almost certainly not what you want)
+        #try:
+        #    fields = fields.items()
+        #except AttributeError:
+        #    pass
+
         columns = [
             self.column_sql(table_name, field_name, field)
             for field_name, field in fields
         ]
         columns = [
             self.column_sql(table_name, field_name, field)
             for field_name, field in fields
         ]
-        
+
         self.execute('CREATE TABLE %s (%s);' % (qn(table_name), ', '.join([col for col in columns if col])))
         self.execute('CREATE TABLE %s (%s);' % (qn(table_name), ', '.join([col for col in columns if col])))
-    
-    add_table = create_table # Alias for consistency's sake
+
+    add_table = alias('create_table') # Alias for consistency's sake
 
 
     def rename_table(self, old_table_name, table_name):
 
 
     def rename_table(self, old_table_name, table_name):
@@ -86,16 +130,26 @@ class DatabaseOperations(object):
         qn = connection.ops.quote_name
         params = (qn(table_name), )
         self.execute('DROP TABLE %s;' % params)
         qn = connection.ops.quote_name
         params = (qn(table_name), )
         self.execute('DROP TABLE %s;' % params)
-    
-    drop_table = delete_table
+
+    drop_table = alias('delete_table')
 
 
 
 
-    def add_column(self, table_name, name, field):
+    def clear_table(self, table_name):
+        """
+        Deletes all rows from 'table_name'.
+        """
+        qn = connection.ops.quote_name
+        params = (qn(table_name), )
+        self.execute('DELETE FROM %s;' % params)
+
+    add_column_string = 'ALTER TABLE %s ADD COLUMN %s;'
+
+    def add_column(self, table_name, name, field, keep_default=True):
         """
         Adds the column 'name' to the table 'table_name'.
         Uses the 'field' paramater, a django.db.models.fields.Field instance,
         to generate the necessary sql
         """
         Adds the column 'name' to the table 'table_name'.
         Uses the 'field' paramater, a django.db.models.fields.Field instance,
         to generate the necessary sql
-        
+
         @param table_name: The name of the table to add the column to
         @param name: The name of the column to add
         @param field: The field to use
         @param table_name: The name of the table to add the column to
         @param name: The name of the column to add
         @param field: The field to use
@@ -107,68 +161,82 @@ class DatabaseOperations(object):
                 qn(table_name),
                 sql,
             )
                 qn(table_name),
                 sql,
             )
-            sql = 'ALTER TABLE %s ADD COLUMN %s;' % params
+            sql = self.add_column_string % params
             self.execute(sql)
             self.execute(sql)
-    
-    
+
+            # Now, drop the default if we need to
+            if not keep_default and field.default:
+                field.default = NOT_PROVIDED
+                self.alter_column(table_name, name, field, explicit_name=False)
+
     alter_string_set_type = 'ALTER COLUMN %(column)s TYPE %(type)s'
     alter_string_set_type = 'ALTER COLUMN %(column)s TYPE %(type)s'
-    alter_string_set_null = 'ALTER COLUMN %(column)s SET NOT NULL'
-    alter_string_drop_null = 'ALTER COLUMN %(column)s DROP NOT NULL'
-    
-    def alter_column(self, table_name, name, field):
+    alter_string_set_null = 'ALTER COLUMN %(column)s DROP NOT NULL'
+    alter_string_drop_null = 'ALTER COLUMN %(column)s SET NOT NULL'
+    allows_combined_alters = True
+
+    def alter_column(self, table_name, name, field, explicit_name=True):
         """
         Alters the given column name so it will match the given field.
         Note that conversion between the two by the database must be possible.
         """
         Alters the given column name so it will match the given field.
         Note that conversion between the two by the database must be possible.
-        
+        Will not automatically add _id by default; to have this behavour, pass
+        explicit_name=False.
+
         @param table_name: The name of the table to add the column to
         @param name: The name of the column to alter
         @param field: The new field definition to use
         """
         @param table_name: The name of the table to add the column to
         @param name: The name of the column to alter
         @param field: The new field definition to use
         """
-        
+
         # hook for the field to do any resolution prior to it's attributes being queried
         if hasattr(field, 'south_init'):
             field.south_init()
         # hook for the field to do any resolution prior to it's attributes being queried
         if hasattr(field, 'south_init'):
             field.south_init()
-        
+
         qn = connection.ops.quote_name
         
         qn = connection.ops.quote_name
         
+        # Add _id or whatever if we need to
+        if not explicit_name:
+            field.set_attributes_from_name(name)
+            name = field.column
+
         # First, change the type
         params = {
             "column": qn(name),
             "type": field.db_type(),
         }
         # First, change the type
         params = {
             "column": qn(name),
             "type": field.db_type(),
         }
-        sqls = [self.alter_string_set_type % params]
-        
-        
+
+        # SQLs is a list of (SQL, values) pairs.
+        sqls = [(self.alter_string_set_type % params, [])]
+
         # Next, set any default
         # Next, set any default
-        params = (
-            qn(name),
-        )
-        
         if not field.null and field.has_default():
             default = field.get_default()
         if not field.null and field.has_default():
             default = field.get_default()
-            if isinstance(default, basestring):
-                default = "'%s'" % default
-            params += ("SET DEFAULT %s",)
+            sqls.append(('ALTER COLUMN %s SET DEFAULT %%s ' % (qn(name),), [default]))
         else:
         else:
-            params += ("DROP DEFAULT",)
-        
-        sqls.append('ALTER COLUMN %s %s ' % params)
-        
-        
+            sqls.append(('ALTER COLUMN %s DROP DEFAULT' % (qn(name),), []))
+
+
         # Next, nullity
         params = {
             "column": qn(name),
             "type": field.db_type(),
         }
         if field.null:
         # Next, nullity
         params = {
             "column": qn(name),
             "type": field.db_type(),
         }
         if field.null:
-            sqls.append(self.alter_string_drop_null % params)
+            sqls.append((self.alter_string_set_null % params, []))
         else:
         else:
-            sqls.append(self.alter_string_set_null % params)
-        
-        
+            sqls.append((self.alter_string_drop_null % params, []))
+
+
         # TODO: Unique
         # TODO: Unique
-        
-        self.execute("ALTER TABLE %s %s;" % (qn(table_name), ", ".join(sqls)))
+
+        if self.allows_combined_alters:
+            sqls, values = zip(*sqls)
+            self.execute(
+                "ALTER TABLE %s %s;" % (qn(table_name), ", ".join(sqls)),
+                flatten(values),
+            )
+        else:
+            # Databases like e.g. MySQL don't like more than one alter at once.
+            for sql, values in sqls:
+                self.execute("ALTER TABLE %s %s;" % (qn(table_name), sql), values)
 
 
     def column_sql(self, table_name, field_name, field, tablespace=''):
 
 
     def column_sql(self, table_name, field_name, field, tablespace=''):
@@ -176,13 +244,13 @@ class DatabaseOperations(object):
         Creates the SQL snippet for a column. Used by add_column and add_table.
         """
         qn = connection.ops.quote_name
         Creates the SQL snippet for a column. Used by add_column and add_table.
         """
         qn = connection.ops.quote_name
-        
+
         field.set_attributes_from_name(field_name)
         field.set_attributes_from_name(field_name)
-        
+
         # hook for the field to do any resolution prior to it's attributes being queried
         if hasattr(field, 'south_init'):
             field.south_init()
         # hook for the field to do any resolution prior to it's attributes being queried
         if hasattr(field, 'south_init'):
             field.south_init()
-        
+
         sql = field.db_type()
         if sql:        
             field_output = [qn(field.column), sql]
         sql = field.db_type()
         if sql:        
             field_output = [qn(field.column), sql]
@@ -190,26 +258,40 @@ class DatabaseOperations(object):
             if field.primary_key:
                 field_output.append('PRIMARY KEY')
             elif field.unique:
             if field.primary_key:
                 field_output.append('PRIMARY KEY')
             elif field.unique:
-                field_output.append('UNIQUE')
-        
+                # Instead of using UNIQUE, add a unique index with a predictable name
+                self.add_deferred_sql(
+                    self.create_index_sql(
+                        table_name,
+                        [field.column],
+                        unique = True,
+                        db_tablespace = tablespace,
+                    )
+                )
+
             tablespace = field.db_tablespace or tablespace
             if tablespace and connection.features.supports_tablespaces and field.unique:
                 # We must specify the index tablespace inline, because we
                 # won't be generating a CREATE INDEX statement for this field.
                 field_output.append(connection.ops.tablespace_sql(tablespace, inline=True))
             tablespace = field.db_tablespace or tablespace
             if tablespace and connection.features.supports_tablespaces and field.unique:
                 # We must specify the index tablespace inline, because we
                 # won't be generating a CREATE INDEX statement for this field.
                 field_output.append(connection.ops.tablespace_sql(tablespace, inline=True))
-            
+
             sql = ' '.join(field_output)
             sqlparams = ()
             # if the field is "NOT NULL" and a default value is provided, create the column with it
             # this allows the addition of a NOT NULL field to a table with existing rows
             if not field.null and field.has_default():
                 default = field.get_default()
             sql = ' '.join(field_output)
             sqlparams = ()
             # if the field is "NOT NULL" and a default value is provided, create the column with it
             # this allows the addition of a NOT NULL field to a table with existing rows
             if not field.null and field.has_default():
                 default = field.get_default()
+                # If the default is a callable, then call it!
+                if callable(default):
+                    default = default()
+                # Now do some very cheap quoting. TODO: Redesign return values to avoid this.
                 if isinstance(default, basestring):
                     default = "'%s'" % default.replace("'", "''")
                 if isinstance(default, basestring):
                     default = "'%s'" % default.replace("'", "''")
+                elif isinstance(default, datetime.date):
+                    default = "'%s'" % default
                 sql += " DEFAULT %s"
                 sqlparams = (default)
                 sql += " DEFAULT %s"
                 sqlparams = (default)
-            
-            if field.rel:
+
+            if field.rel and self.supports_foreign_keys:
                 self.add_deferred_sql(
                     self.foreign_key_sql(
                         table_name,
                 self.add_deferred_sql(
                     self.foreign_key_sql(
                         table_name,
@@ -218,10 +300,10 @@ class DatabaseOperations(object):
                         field.rel.to._meta.get_field(field.rel.field_name).column
                     )
                 )
                         field.rel.to._meta.get_field(field.rel.field_name).column
                     )
                 )
-            
+
             if field.db_index and not field.unique:
                 self.add_deferred_sql(self.create_index_sql(table_name, [field.column]))
             if field.db_index and not field.unique:
                 self.add_deferred_sql(self.create_index_sql(table_name, [field.column]))
-            
+
         if hasattr(field, 'post_create_sql'):
             style = no_style()
             for stmt in field.post_create_sql(style, table_name):
         if hasattr(field, 'post_create_sql'):
             style = no_style()
             for stmt in field.post_create_sql(style, table_name):
@@ -231,21 +313,26 @@ class DatabaseOperations(object):
             return sql % sqlparams
         else:
             return None
             return sql % sqlparams
         else:
             return None
-        
+
+
+    supports_foreign_keys = True
+
     def foreign_key_sql(self, from_table_name, from_column_name, to_table_name, to_column_name):
         """
         Generates a full SQL statement to add a foreign key constraint
         """
     def foreign_key_sql(self, from_table_name, from_column_name, to_table_name, to_column_name):
         """
         Generates a full SQL statement to add a foreign key constraint
         """
+        qn = connection.ops.quote_name
         constraint_name = '%s_refs_%s_%x' % (from_column_name, to_column_name, abs(hash((from_table_name, to_table_name))))
         return 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)%s;' % (
         constraint_name = '%s_refs_%s_%x' % (from_column_name, to_column_name, abs(hash((from_table_name, to_table_name))))
         return 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)%s;' % (
-            from_table_name,
-            truncate_name(constraint_name, connection.ops.max_name_length()),
-            from_column_name,
-            to_table_name,
-            to_column_name,
+            qn(from_table_name),
+            qn(truncate_name(constraint_name, connection.ops.max_name_length())),
+            qn(from_column_name),
+            qn(to_table_name),
+            qn(to_column_name),
             connection.ops.deferrable_sql() # Django knows this
         )
             connection.ops.deferrable_sql() # Django knows this
         )
-        
+
+
     def create_index_name(self, table_name, column_names):
         """
         Generate a unique name for the index
     def create_index_name(self, table_name, column_names):
         """
         Generate a unique name for the index
@@ -256,45 +343,55 @@ class DatabaseOperations(object):
 
         return '%s_%s%s' % (table_name, column_names[0], index_unique_name)
 
 
         return '%s_%s%s' % (table_name, column_names[0], index_unique_name)
 
+
     def create_index_sql(self, table_name, column_names, unique=False, db_tablespace=''):
         """
         Generates a create index statement on 'table_name' for a list of 'column_names'
         """
     def create_index_sql(self, table_name, column_names, unique=False, db_tablespace=''):
         """
         Generates a create index statement on 'table_name' for a list of 'column_names'
         """
+        qn = connection.ops.quote_name
         if not column_names:
             print "No column names supplied on which to create an index"
             return ''
         if not column_names:
             print "No column names supplied on which to create an index"
             return ''
-            
+
         if db_tablespace and connection.features.supports_tablespaces:
             tablespace_sql = ' ' + connection.ops.tablespace_sql(db_tablespace)
         else:
             tablespace_sql = ''
         if db_tablespace and connection.features.supports_tablespaces:
             tablespace_sql = ' ' + connection.ops.tablespace_sql(db_tablespace)
         else:
             tablespace_sql = ''
-        
+
         index_name = self.create_index_name(table_name, column_names)
         qn = connection.ops.quote_name
         return 'CREATE %sINDEX %s ON %s (%s)%s;' % (
             unique and 'UNIQUE ' or '',
         index_name = self.create_index_name(table_name, column_names)
         qn = connection.ops.quote_name
         return 'CREATE %sINDEX %s ON %s (%s)%s;' % (
             unique and 'UNIQUE ' or '',
-            index_name,
-            table_name,
+            qn(index_name),
+            qn(table_name),
             ','.join([qn(field) for field in column_names]),
             tablespace_sql
             ','.join([qn(field) for field in column_names]),
             tablespace_sql
-            )
-        
+        )
+
     def create_index(self, table_name, column_names, unique=False, db_tablespace=''):
         """ Executes a create index statement """
         sql = self.create_index_sql(table_name, column_names, unique, db_tablespace)
         self.execute(sql)
 
 
     def create_index(self, table_name, column_names, unique=False, db_tablespace=''):
         """ Executes a create index statement """
         sql = self.create_index_sql(table_name, column_names, unique, db_tablespace)
         self.execute(sql)
 
 
+    drop_index_string = 'DROP INDEX %(index_name)s'
+
     def delete_index(self, table_name, column_names, db_tablespace=''):
         """
         Deletes an index created with create_index.
         This is possible using only columns due to the deterministic
         index naming function which relies on column names.
         """
     def delete_index(self, table_name, column_names, db_tablespace=''):
         """
         Deletes an index created with create_index.
         This is possible using only columns due to the deterministic
         index naming function which relies on column names.
         """
+        if isinstance(column_names, (str, unicode)):
+            column_names = [column_names]
         name = self.create_index_name(table_name, column_names)
         name = self.create_index_name(table_name, column_names)
-        sql = "DROP INDEX %s" % name
+        qn = connection.ops.quote_name
+        sql = self.drop_index_string % {"index_name": qn(name), "table_name": qn(table_name)}
         self.execute(sql)
 
         self.execute(sql)
 
+    drop_index = alias('delete_index')
+
+    delete_column_string = 'ALTER TABLE %s DROP COLUMN %s CASCADE;'
 
     def delete_column(self, table_name, name):
         """
 
     def delete_column(self, table_name, name):
         """
@@ -302,7 +399,9 @@ class DatabaseOperations(object):
         """
         qn = connection.ops.quote_name
         params = (qn(table_name), qn(name))
         """
         qn = connection.ops.quote_name
         params = (qn(table_name), qn(name))
-        self.execute('ALTER TABLE %s DROP COLUMN %s CASCADE;' % params, [])
+        self.execute(self.delete_column_string % params, [])
+
+    drop_column = alias('delete_column')
 
 
     def rename_column(self, table_name, old, new):
 
 
     def rename_column(self, table_name, old, new):
@@ -317,6 +416,8 @@ class DatabaseOperations(object):
         Makes sure the following commands are inside a transaction.
         Must be followed by a (commit|rollback)_transaction call.
         """
         Makes sure the following commands are inside a transaction.
         Must be followed by a (commit|rollback)_transaction call.
         """
+        if self.dry_run:
+            return
         transaction.commit_unless_managed()
         transaction.enter_transaction_management()
         transaction.managed(True)
         transaction.commit_unless_managed()
         transaction.enter_transaction_management()
         transaction.managed(True)
@@ -327,6 +428,8 @@ class DatabaseOperations(object):
         Commits the current transaction.
         Must be preceded by a start_transaction call.
         """
         Commits the current transaction.
         Must be preceded by a start_transaction call.
         """
+        if self.dry_run:
+            return
         transaction.commit()
         transaction.leave_transaction_management()
 
         transaction.commit()
         transaction.leave_transaction_management()
 
@@ -336,53 +439,67 @@ class DatabaseOperations(object):
         Rolls back the current transaction.
         Must be preceded by a start_transaction call.
         """
         Rolls back the current transaction.
         Must be preceded by a start_transaction call.
         """
+        if self.dry_run:
+            return
         transaction.rollback()
         transaction.leave_transaction_management()
         transaction.rollback()
         transaction.leave_transaction_management()
-    
-    
+
+
     def send_create_signal(self, app_label, model_names):
     def send_create_signal(self, app_label, model_names):
+        self.pending_create_signals.append((app_label, model_names))
+
+
+    def send_pending_create_signals(self):
+        for (app_label, model_names) in self.pending_create_signals:
+            self.really_send_create_signal(app_label, model_names)
+        self.pending_create_signals = []
+
+
+    def really_send_create_signal(self, app_label, model_names):
         """
         Sends a post_syncdb signal for the model specified.
         """
         Sends a post_syncdb signal for the model specified.
-        
+
         If the model is not found (perhaps it's been deleted?),
         no signal is sent.
         If the model is not found (perhaps it's been deleted?),
         no signal is sent.
-        
+
         TODO: The behavior of django.contrib.* apps seems flawed in that
         they don't respect created_models.  Rather, they blindly execute
         over all models within the app sending the signal.  This is a
         patch we should push Django to make  For now, this should work.
         """
         TODO: The behavior of django.contrib.* apps seems flawed in that
         they don't respect created_models.  Rather, they blindly execute
         over all models within the app sending the signal.  This is a
         patch we should push Django to make  For now, this should work.
         """
+        if self.debug:
+            print " - Sending post_syncdb signal for %s: %s" % (app_label, model_names)
         app = models.get_app(app_label)
         if not app:
             return
         app = models.get_app(app_label)
         if not app:
             return
-            
+
         created_models = []
         for model_name in model_names:
             model = models.get_model(app_label, model_name)
             if model:
                 created_models.append(model)
         created_models = []
         for model_name in model_names:
             model = models.get_model(app_label, model_name)
             if model:
                 created_models.append(model)
-                
+
         if created_models:
             # syncdb defaults -- perhaps take these as options?
             verbosity = 1
             interactive = True
         if created_models:
             # syncdb defaults -- perhaps take these as options?
             verbosity = 1
             interactive = True
-            
+
             if hasattr(dispatcher, "send"):
                 dispatcher.send(signal=models.signals.post_syncdb, sender=app,
             if hasattr(dispatcher, "send"):
                 dispatcher.send(signal=models.signals.post_syncdb, sender=app,
-                app=app, created_models=created_models,
-                verbosity=verbosity, interactive=interactive)
+                                app=app, created_models=created_models,
+                                verbosity=verbosity, interactive=interactive)
             else:
                 models.signals.post_syncdb.send(sender=app,
             else:
                 models.signals.post_syncdb.send(sender=app,
-                app=app, created_models=created_models,
-                verbosity=verbosity, interactive=interactive)
-                
+                                                app=app, created_models=created_models,
+                                                verbosity=verbosity, interactive=interactive)
+
     def mock_model(self, model_name, db_table, db_tablespace='', 
     def mock_model(self, model_name, db_table, db_tablespace='', 
-                    pk_field_name='id', pk_field_type=models.AutoField,
-                    pk_field_kwargs={}):
+                   pk_field_name='id', pk_field_type=models.AutoField,
+                   pk_field_args=[], pk_field_kwargs={}):
         """
         Generates a MockModel class that provides enough information
         to be used by a foreign key/many-to-many relationship.
         """
         Generates a MockModel class that provides enough information
         to be used by a foreign key/many-to-many relationship.
-        
+
         Migrations should prefer to use these rather than actual models
         as models could get deleted over time, but these can remain in
         migration files forever.
         Migrations should prefer to use these rather than actual models
         as models could get deleted over time, but these can remain in
         migration files forever.
@@ -397,7 +514,7 @@ class DatabaseOperations(object):
                 if pk_field_type == models.AutoField:
                     pk_field_kwargs['primary_key'] = True
 
                 if pk_field_type == models.AutoField:
                     pk_field_kwargs['primary_key'] = True
 
-                self.pk = pk_field_type(**pk_field_kwargs)
+                self.pk = pk_field_type(*pk_field_args, **pk_field_kwargs)
                 self.pk.set_attributes_from_name(pk_field_name)
                 self.abstract = False
 
                 self.pk.set_attributes_from_name(pk_field_name)
                 self.abstract = False
 
@@ -416,3 +533,11 @@ class DatabaseOperations(object):
         MockModel._meta = MockOptions()
         MockModel._meta.model = MockModel
         return MockModel
         MockModel._meta = MockOptions()
         MockModel._meta.model = MockModel
         return MockModel
+
+# Single-level flattening of lists
+def flatten(ls):
+    nl = []
+    for l in ls:
+        nl += l
+    return nl
+
index c3659fc..a05c071 100644 (file)
@@ -1,5 +1,6 @@
 
 from django.db import connection
 
 from django.db import connection
+from django.conf import settings
 from south.db import generic
 
 class DatabaseOperations(generic.DatabaseOperations):
 from south.db import generic
 
 class DatabaseOperations(generic.DatabaseOperations):
@@ -11,9 +12,20 @@ class DatabaseOperations(generic.DatabaseOperations):
     alter_string_set_type = ''
     alter_string_set_null = 'MODIFY %(column)s %(type)s NULL;'
     alter_string_drop_null = 'MODIFY %(column)s %(type)s NOT NULL;'
     alter_string_set_type = ''
     alter_string_set_null = 'MODIFY %(column)s %(type)s NULL;'
     alter_string_drop_null = 'MODIFY %(column)s %(type)s NOT NULL;'
+    drop_index_string = 'DROP INDEX %(index_name)s ON %(table_name)s'
+    allows_combined_alters = False
+    has_ddl_transactions = False
+    
+    def execute(self, sql, params=[]):
+        if hasattr(settings, "DATABASE_STORAGE_ENGINE") and \
+           settings.DATABASE_STORAGE_ENGINE:
+            generic.DatabaseOperations.execute(self, "SET storage_engine=%s;" %
+                settings.DATABASE_STORAGE_ENGINE)
+        return generic.DatabaseOperations.execute(self, sql, params)
+    execute.__doc__ = generic.DatabaseOperations.execute.__doc__
 
     def rename_column(self, table_name, old, new):
 
     def rename_column(self, table_name, old, new):
-        if old == new:
+        if old == new or self.dry_run:
             return []
         
         qn = connection.ops.quote_name
             return []
         
         qn = connection.ops.quote_name
@@ -27,17 +39,22 @@ class DatabaseOperations(generic.DatabaseOperations):
             qn(table_name),
             qn(old),
             qn(new),
             qn(table_name),
             qn(old),
             qn(new),
-            "%s %s %s %s %s" % (
-                rows[0][1],
-                rows[0][2] == "YES" and "NULL" or "NOT NULL",
-                rows[0][3] == "PRI" and "PRIMARY KEY" or "",
-                rows[0][4] and "DEFAULT %s" % rows[0][4] or "",
-                rows[0][5] or "",
-            ),
+            rows[0][1],
+            rows[0][2] == "YES" and "NULL" or "NOT NULL",
+            rows[0][3] == "PRI" and "PRIMARY KEY" or "",
+            rows[0][4] and "DEFAULT " or "",
+            rows[0][4] and "%s" or "",
+            rows[0][5] or "",
         )
         )
-        self.execute('ALTER TABLE %s CHANGE COLUMN %s %s %s;' % params)
-
-
+        
+        sql = 'ALTER TABLE %s CHANGE COLUMN %s %s %s %s %s %s %s %s;' % params
+        
+        if rows[0][4]:
+            self.execute(sql, (rows[0][4],))
+        else:
+            self.execute(sql)
+    
+    
     def rename_table(self, old_table_name, table_name):
         """
         Renames the table 'old_table_name' to 'table_name'.
     def rename_table(self, old_table_name, table_name):
         """
         Renames the table 'old_table_name' to 'table_name'.
@@ -47,4 +64,4 @@ class DatabaseOperations(generic.DatabaseOperations):
             return
         qn = connection.ops.quote_name
         params = (qn(old_table_name), qn(table_name))
             return
         qn = connection.ops.quote_name
         params = (qn(old_table_name), qn(table_name))
-        self.execute('RENAME TABLE %s TO %s;' % params)
\ No newline at end of file
+        self.execute('RENAME TABLE %s TO %s;' % params)
index 278eb3e..839b4b1 100644 (file)
@@ -16,6 +16,7 @@ class DatabaseOperations(generic.DatabaseOperations):
         self.execute('ALTER TABLE %s RENAME COLUMN %s TO %s;' % params)
     
     def rename_table(self, old_table_name, table_name):
         self.execute('ALTER TABLE %s RENAME COLUMN %s TO %s;' % params)
     
     def rename_table(self, old_table_name, table_name):
+        "will rename the table and an associated ID sequence and primary key index"
         # First, rename the table
         generic.DatabaseOperations.rename_table(self, old_table_name, table_name)
         # Then, try renaming the ID sequence
         # First, rename the table
         generic.DatabaseOperations.rename_table(self, old_table_name, table_name)
         # Then, try renaming the ID sequence
@@ -25,8 +26,27 @@ class DatabaseOperations(generic.DatabaseOperations):
         try:
             generic.DatabaseOperations.rename_table(self, old_table_name+"_id_seq", table_name+"_id_seq")
         except:
         try:
             generic.DatabaseOperations.rename_table(self, old_table_name+"_id_seq", table_name+"_id_seq")
         except:
-            print "   ~ No such sequence (ignoring error)"
+            if self.debug:
+                print "   ~ No such sequence (ignoring error)"
             self.rollback_transaction()
         else:
             self.commit_transaction()
             self.rollback_transaction()
         else:
             self.commit_transaction()
-        self.start_transaction()
\ No newline at end of file
+        self.start_transaction()
+
+        # Rename primary key index, will not rename other indices on
+        # the table that are used by django (e.g. foreign keys). Until
+        # figure out how, you need to do this yourself.
+        try:
+            generic.DatabaseOperations.rename_table(self, old_table_name+"_pkey", table_name+ "_pkey")
+        except:
+            if self.debug:
+                print "   ~ No such primary key (ignoring error)"
+            self.rollback_transaction()
+        else:
+            self.commit_transaction()
+        self.start_transaction()
+
+
+    def rename_index(self, old_index_name, index_name):
+        "Rename an index individually"
+        generic.DatabaseOperations.rename_table(self, old_index_name, index_name)
diff --git a/apps/south/db/sql_server/__init__.py b/apps/south/db/sql_server/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/south/db/sql_server/pyodbc.py b/apps/south/db/sql_server/pyodbc.py
new file mode 100644 (file)
index 0000000..58c5166
--- /dev/null
@@ -0,0 +1,25 @@
+from django.db import connection
+from django.db.models.fields import *
+from south.db import generic
+
+class DatabaseOperations(generic.DatabaseOperations):
+    """
+    django-pyodbc (sql_server.pyodbc) implementation of database operations.
+    """
+    
+    add_column_string = 'ALTER TABLE %s ADD %s;'
+    alter_string_set_type = 'ALTER COLUMN %(column)s %(type)s'
+    allows_combined_alters = False
+    delete_column_string = 'ALTER TABLE %s DROP COLUMN %s;'
+
+    def create_table(self, table_name, fields):
+        # Tweak stuff as needed
+        for name,f in fields:
+            if isinstance(f, BooleanField):
+                if f.default == True:
+                    f.default = 1
+                if f.default == False:
+                    f.default = 0
+
+        # Run
+        generic.DatabaseOperations.create_table(self, table_name, fields)
index 6073b4d..1fac1b8 100644 (file)
@@ -8,5 +8,35 @@ class DatabaseOperations(generic.DatabaseOperations):
     SQLite3 implementation of database operations.
     """
 
     SQLite3 implementation of database operations.
     """
 
-    def __init__(self):
-        raise NotImplementedError("Support for SQLite3 is not yet complete.")
\ No newline at end of file
+    # SQLite ignores foreign key constraints. I wish I could.
+    supports_foreign_keys = False
+    
+    # You can't add UNIQUE columns with an ALTER TABLE.
+    def add_column(self, table_name, name, field, *args, **kwds):
+        # Run ALTER TABLE with no unique column
+        unique, field._unique, field.db_index = field.unique, False, False
+        generic.DatabaseOperations.add_column(self, table_name, name, field, *args, **kwds)
+        # If it _was_ unique, make an index on it.
+        if unique:
+            self.create_index(table_name, [name], unique=True)
+    
+    # SQLite doesn't have ALTER COLUMN
+    def alter_column(self, table_name, name, field, explicit_name=True):
+        """
+        Not supported under SQLite.
+        """
+        raise NotImplementedError("SQLite does not support altering columns.")
+    
+    # Nor DROP COLUMN
+    def delete_column(self, table_name, name, field):
+        """
+        Not supported under SQLite.
+        """
+        raise NotImplementedError("SQLite does not support deleting columns.")
+    
+    # Nor RENAME COLUMN
+    def rename_column(self, table_name, old, new):
+        """
+        Not supported under SQLite.
+        """
+        raise NotImplementedError("SQLite does not support renaming columns.")
\ No newline at end of file
diff --git a/apps/south/install/README b/apps/south/install/README
deleted file mode 100644 (file)
index 897b51d..0000000
+++ /dev/null
@@ -1 +0,0 @@
-To use this setup.py, make sure you checked out this trunk or branch into a directory called 'south', copy the setup.py into the directory above it, and off you go.
diff --git a/apps/south/install/setup.py b/apps/south/install/setup.py
deleted file mode 100755 (executable)
index 6da3d4a..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/python
-
-from setuptools import setup, find_packages
-
-setup(
-    name='South',
-    version='0.3',
-    description='South: Migrations for Django',
-    author='Andrew Godwin & Andy McCurdy',
-    author_email='south@aeracode.org',
-    url='http://south.aeracode.org/',
-    packages=["south", "south.db", "south.management", "south.management.commands", "south.tests"],
-)
index d2d6998..1cc2a29 100644 (file)
@@ -16,11 +16,20 @@ class Command(BaseCommand):
             help='Only runs or rolls back the migration specified, and none around it.'),
         make_option('--fake', action='store_true', dest='fake', default=False,
             help="Pretends to do the migrations, but doesn't actually execute them."),
             help='Only runs or rolls back the migration specified, and none around it.'),
         make_option('--fake', action='store_true', dest='fake', default=False,
             help="Pretends to do the migrations, but doesn't actually execute them."),
+
+        make_option('--db-dry-run', action='store_true', dest='db_dry_run', default=False,
+            help="Doesn't execute the SQL generated by the db methods, and doesn't store a record that the migration(s) occurred. Useful to test migrations before applying them."),
     )
     )
+    if '--verbosity' not in [opt.get_opt_string() for opt in BaseCommand.option_list]:
+        option_list += (
+            make_option('--verbosity', action='store', dest='verbosity', default='1',
+            type='choice', choices=['0', '1', '2'],
+            help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'),
+        )
     help = "Runs migrations for all apps."
 
     help = "Runs migrations for all apps."
 
-    def handle(self, app=None, target=None, skip=False, merge=False, only=False, backwards=False, fake=False, **options):
-        
+    def handle(self, app=None, target=None, skip=False, merge=False, only=False, backwards=False, fake=False, db_dry_run=False, **options):
+
         # Work out what the resolve mode is
         resolve_mode = merge and "merge" or (skip and "skip" or None)
         # Turn on db debugging
         # Work out what the resolve mode is
         resolve_mode = merge and "merge" or (skip and "skip" or None)
         # Turn on db debugging
@@ -46,10 +55,14 @@ class Command(BaseCommand):
             apps = [migration.get_app(app)]
         else:
             apps = migration.get_migrated_apps()
             apps = [migration.get_app(app)]
         else:
             apps = migration.get_migrated_apps()
+        silent = options.get('verbosity', 0) == 0
         for app in apps:
             migration.migrate_app(
                 app,
                 resolve_mode = resolve_mode,
                 target_name = target,
                 fake = fake,
         for app in apps:
             migration.migrate_app(
                 app,
                 resolve_mode = resolve_mode,
                 target_name = target,
                 fake = fake,
+                db_dry_run = db_dry_run,
+                silent = silent,
+                load_inital_data = True,
             )
             )
index f52efe7..1a8da99 100644 (file)
@@ -3,6 +3,7 @@ from django.core.management.color import no_style
 from django.db import models
 from django.db.models.fields.related import RECURSIVE_RELATIONSHIP_CONSTANT
 from django.contrib.contenttypes.generic import GenericRelation
 from django.db import models
 from django.db.models.fields.related import RECURSIVE_RELATIONSHIP_CONSTANT
 from django.contrib.contenttypes.generic import GenericRelation
+from django.db.models.fields import FieldDoesNotExist
 from optparse import make_option
 from south import migration
 import sys
 from optparse import make_option
 from south import migration
 import sys
@@ -17,18 +18,23 @@ class Command(BaseCommand):
     option_list = BaseCommand.option_list + (
         make_option('--model', action='append', dest='model_list', type='string',
             help='Generate a Create Table migration for the specified model.  Add multiple models to this migration with subsequent --model parameters.'),
     option_list = BaseCommand.option_list + (
         make_option('--model', action='append', dest='model_list', type='string',
             help='Generate a Create Table migration for the specified model.  Add multiple models to this migration with subsequent --model parameters.'),
+        make_option('--add-field', action='append', dest='field_list', type='string',
+            help='Generate an Add Column migration for the specified modelname.fieldname - you can use this multiple times to add more than one column.'),
         make_option('--initial', action='store_true', dest='initial', default=False,
             help='Generate the initial schema for the app.'),
     )
     help = "Creates a new template migration for the given app"
     
         make_option('--initial', action='store_true', dest='initial', default=False,
             help='Generate the initial schema for the app.'),
     )
     help = "Creates a new template migration for the given app"
     
-    def handle(self, app=None, name="", model_list=None, initial=False, **options):
+    def handle(self, app=None, name="", model_list=None, field_list=None, initial=False, **options):
         
         # If model_list is None, then it's an empty list
         model_list = model_list or []
         
         
         # If model_list is None, then it's an empty list
         model_list = model_list or []
         
+        # If field_list is None, then it's an empty list
+        field_list = field_list or []
+        
         # make sure --model and --all aren't both specified
         # make sure --model and --all aren't both specified
-        if initial and model_list:
+        if initial and (model_list or field_list):
             print "You cannot use --initial and other options together"
             return
             
             print "You cannot use --initial and other options together"
             return
             
@@ -67,7 +73,22 @@ class Command(BaseCommand):
                     return
                     
                 models_to_migrate.append(model)
                     return
                     
                 models_to_migrate.append(model)
-                
+        
+        # See what fields need to be included
+        fields_to_add = []
+        for field_spec in field_list:
+            model_name, field_name = field_spec.split(".", 1)
+            model = models.get_model(app, model_name)
+            if not model:
+                print "Couldn't find model '%s' in app '%s'" % (model_name, app)
+                return
+            try:
+                field = model._meta.get_field(field_name)
+            except FieldDoesNotExist:
+                print "Model '%s' doesn't have a field '%s'" % (model_name, field_name)
+                return
+            fields_to_add.append((model, field_name, field))
+        
         # Make the migrations directory if it's not there
         app_module_path = app_models_module.__name__.split('.')[0:-1]
         try:
         # Make the migrations directory if it's not there
         app_module_path = app_models_module.__name__.split('.')[0:-1]
         try:
@@ -80,11 +101,15 @@ class Command(BaseCommand):
             os.path.dirname(app_module.__file__),
             "migrations",
         )
             os.path.dirname(app_module.__file__),
             "migrations",
         )
+        # Make sure there's a migrations directory and __init__.py
         if not os.path.isdir(migrations_dir):
             print "Creating migrations directory at '%s'..." % migrations_dir
             os.mkdir(migrations_dir)
         if not os.path.isdir(migrations_dir):
             print "Creating migrations directory at '%s'..." % migrations_dir
             os.mkdir(migrations_dir)
+        init_path = os.path.join(migrations_dir, "__init__.py")
+        if not os.path.isfile(init_path):
             # Touch the init py file
             # Touch the init py file
-            open(os.path.join(migrations_dir, "__init__.py"), "w").close()
+            print "Creating __init__.py in '%s'..." % migrations_dir
+            open(init_path, "w").close()
         # See what filename is next in line. We assume they use numbers.
         migrations = migration.get_migration_names(migration.get_app(app))
         highest_number = 0
         # See what filename is next in line. We assume they use numbers.
         migrations = migration.get_migration_names(migration.get_app(app))
         highest_number = 0
@@ -102,23 +127,110 @@ class Command(BaseCommand):
         )
         # If there's a model, make the migration skeleton, else leave it bare
         forwards, backwards = '', ''
         )
         # If there's a model, make the migration skeleton, else leave it bare
         forwards, backwards = '', ''
+        if fields_to_add:
+            # First, do the added fields
+            for model, field_name, field in fields_to_add:
+                field_definition = generate_field_definition(model, field)
+                
+                if isinstance(field, models.ManyToManyField):
+                    # Make a mock model for each side
+                    mock_model = "\n".join([
+                        create_mock_model(model, "        "), 
+                        create_mock_model(field.rel.to, "        ")
+                    ])
+                    # And a field defn, that's actually a table creation
+                    forwards += '''
+        # Mock Model
+%s
+        # Adding ManyToManyField '%s.%s'
+        db.create_table('%s', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('%s', models.ForeignKey(%s, null=False)),
+            ('%s', models.ForeignKey(%s, null=False))
+        )) ''' % (
+                mock_model,
+                model._meta.object_name,
+                field.name,
+                field.m2m_db_table(),
+                field.m2m_column_name()[:-3], # strip off the '_id' at the end
+                model._meta.object_name,
+                field.m2m_reverse_name()[:-3], # strip off the '_id' at the ned
+                field.rel.to._meta.object_name
+                )
+                    backwards += '''
+        # Dropping ManyToManyField '%s.%s'
+        db.drop_table('%s')''' % (
+                        model._meta.object_name,
+                        field.name,
+                        field.m2m_db_table()
+                    )
+                    continue
+                elif field.rel: # ForeignKey, etc.
+                    mock_model = create_mock_model(field.rel.to, "        ")
+                    field_definition = related_field_definition(field, field_definition)
+                else:
+                    mock_model = None
+                
+                # If we can't get it (inspect madness?) then insert placeholder
+                if not field_definition:
+                    print "Warning: Could not generate field definition for %s.%s, manual editing of migration required." % \
+                                (model._meta.object_name, field.name)
+                    field_definition = '<<< REPLACE THIS WITH FIELD DEFINITION FOR %s.%s >>>' % (model._meta.object_name, f.name)
+                
+                if mock_model:
+                    forwards += '''
+        # Mock model
+%s
+        ''' % (mock_model)
+                
+                forwards += '''
+        # Adding field '%s.%s'
+        db.add_column(%r, %r, %s)
+        ''' % (
+            model._meta.object_name,
+            field.name,
+            model._meta.db_table,
+            field.name,
+            field_definition,
+        )
+                backwards += '''
+        # Deleting field '%s.%s'
+        db.delete_column(%r, %r)
+        ''' % (
+            model._meta.object_name,
+            field.name,
+            model._meta.db_table,
+            field.column,
+        )
+        
         if models_to_migrate:
         if models_to_migrate:
+            # Now, do the added models
             for model in models_to_migrate:
                 table_name = model._meta.db_table
                 mock_models = []
                 fields = []
                 for f in model._meta.local_fields:
             for model in models_to_migrate:
                 table_name = model._meta.db_table
                 mock_models = []
                 fields = []
                 for f in model._meta.local_fields:
-                    # look up the field definition to see how this was created
+                    
+                    # Look up the field definition to see how this was created
                     field_definition = generate_field_definition(model, f)
                     field_definition = generate_field_definition(model, f)
-                    if field_definition:
+                    
+                    # If it's a OneToOneField, and ends in _ptr, just use it
+                    if isinstance(f, models.OneToOneField) and f.name.endswith("_ptr"):
+                        mock_models.append(create_mock_model(f.rel.to, "        "))
+                        field_definition = "models.OneToOneField(%s)" % f.rel.to.__name__
+                    
+                    # It's probably normal then
+                    elif field_definition:
                         
                         if isinstance(f, models.ForeignKey):
                         
                         if isinstance(f, models.ForeignKey):
-                            mock_models.append(create_mock_model(f.rel.to))
+                            mock_models.append(create_mock_model(f.rel.to, "        "))
                             field_definition = related_field_definition(f, field_definition)
                             field_definition = related_field_definition(f, field_definition)
-                            
+                    
+                    # Oh noes, no defn found
                     else:
                         print "Warning: Could not generate field definition for %s.%s, manual editing of migration required." % \
                                 (model._meta.object_name, f.name)
                     else:
                         print "Warning: Could not generate field definition for %s.%s, manual editing of migration required." % \
                                 (model._meta.object_name, f.name)
+                        print f, type(f)
                                 
                         field_definition = '<<< REPLACE THIS WITH FIELD DEFINITION FOR %s.%s >>>' % (model._meta.object_name, f.name)
                                                 
                                 
                         field_definition = '<<< REPLACE THIS WITH FIELD DEFINITION FOR %s.%s >>>' % (model._meta.object_name, f.name)
                                                 
@@ -128,12 +240,12 @@ class Command(BaseCommand):
                     forwards += '''
         
         # Mock Models
                     forwards += '''
         
         # Mock Models
-        %s
-        ''' % "\n        ".join(mock_models)
+%s
+        ''' % "\n".join(mock_models)
         
                 forwards += '''
         # Model '%s'
         
                 forwards += '''
         # Model '%s'
-        db.create_table('%s', (
+        db.create_table(%r, (
             %s
         ))''' % (
                     model._meta.object_name,
             %s
         ))''' % (
                     model._meta.object_name,
@@ -155,11 +267,11 @@ class Command(BaseCommand):
                     if m.rel.through:
                         continue
                         
                     if m.rel.through:
                         continue
                         
-                    mock_models = [create_mock_model(model), create_mock_model(m.rel.to)]
+                    mock_models = [create_mock_model(model, "        "), create_mock_model(m.rel.to, "        ")]
                     
                     forwards += '''
         # Mock Models
                     
                     forwards += '''
         # Mock Models
-        %s
+%s
         
         # M2M field '%s.%s'
         db.create_table('%s', (
         
         # M2M field '%s.%s'
         db.create_table('%s', (
@@ -167,7 +279,7 @@ class Command(BaseCommand):
             ('%s', models.ForeignKey(%s, null=False)),
             ('%s', models.ForeignKey(%s, null=False))
         )) ''' % (
             ('%s', models.ForeignKey(%s, null=False)),
             ('%s', models.ForeignKey(%s, null=False))
         )) ''' % (
-                        "\n        ".join(mock_models),
+                        "\n".join(mock_models),
                         model._meta.object_name,
                         m.name,
                         m.m2m_db_table(),
                         model._meta.object_name,
                         m.name,
                         m.m2m_db_table(),
@@ -204,12 +316,20 @@ class Command(BaseCommand):
                 "','".join(model._meta.object_name for model in models_to_migrate)
                 )
         
                 "','".join(model._meta.object_name for model in models_to_migrate)
                 )
         
-        else:
+        # Try sniffing the encoding using PEP 0263's method
+        encoding = None
+        first_two_lines = inspect.getsourcelines(app_models_module)[0][:2]
+        for line in first_two_lines:
+            if re.search("coding[:=]\s*([-\w.]+)", line):
+                encoding = line
+        
+        if (not forwards) and (not backwards):
             forwards = '"Write your forwards migration here"'
             backwards = '"Write your backwards migration here"'
         fp = open(os.path.join(migrations_dir, new_filename), "w")
             forwards = '"Write your forwards migration here"'
             backwards = '"Write your backwards migration here"'
         fp = open(os.path.join(migrations_dir, new_filename), "w")
-        fp.write("""
+        fp.write("""%s
 from south.db import db
 from south.db import db
+from django.db import models
 from %s.models import *
 
 class Migration:
 from %s.models import *
 
 class Migration:
@@ -219,7 +339,7 @@ class Migration:
     
     def backwards(self):
         %s
     
     def backwards(self):
         %s
-""" % ('.'.join(app_module_path), forwards, backwards))
+""" % (encoding or "", '.'.join(app_module_path), forwards, backwards))
         fp.close()
         print "Created %s." % new_filename
 
         fp.close()
         print "Created %s." % new_filename
 
@@ -248,8 +368,11 @@ def generate_field_definition(model, field):
             # the correct comment.
             if test_field(stripped_definition):
                 return stripped_definition
             # the correct comment.
             if test_field(stripped_definition):
                 return stripped_definition
-                
-            index = field_definition.index('#', index+1)
+            
+            try:    
+                index = field_definition.index('#', index+1)
+            except ValueError:
+                break
             
         return field_definition
         
             
         return field_definition
         
@@ -263,7 +386,7 @@ def generate_field_definition(model, field):
     source = inspect.getsourcelines(model)
     if not source:
         raise Exception("Could not find source to model: '%s'" % (model.__name__))
     source = inspect.getsourcelines(model)
     if not source:
         raise Exception("Could not find source to model: '%s'" % (model.__name__))
-        
+    
     # look for a line starting with the field name
     start_field_re = re.compile(r'\s*%s\s*=\s*(.*)' % field.name)
     for line in source[0]:
     # look for a line starting with the field name
     start_field_re = re.compile(r'\s*%s\s*=\s*(.*)' % field.name)
     for line in source[0]:
@@ -329,21 +452,40 @@ def related_field_definition(field, field_definition):
     
     return field_definition
 
     
     return field_definition
 
-def create_mock_model(model):
+def create_mock_model(model, indent="        "):
     # produce a string representing the python syntax necessary for creating
     # a mock model using the supplied real model
     # produce a string representing the python syntax necessary for creating
     # a mock model using the supplied real model
-    if model._meta.pk.__class__.__module__ != 'django.db.models.fields':
+    if not model._meta.pk.__class__.__module__.startswith('django.db.models.fields'):
         # we can fix this with some clever imports, but it doesn't seem necessary to
         # spend time on just yet
         # we can fix this with some clever imports, but it doesn't seem necessary to
         # spend time on just yet
-        print "Can't generate a mock model for %s because it's primary key isn't a default django field" % model
+        print "Can't generate a mock model for %s because it's primary key isn't a default django field; it's type %s." % (model, model._meta.pk.__class__)
         sys.exit()
     
         sys.exit()
     
-    return "%s = db.mock_model(model_name='%s', db_table='%s', db_tablespace='%s', pk_field_name='%s', pk_field_type=models.%s)" % \
+    pk_field_args = []
+    pk_field_kwargs = {}
+    other_mocks = []
+    # If it's a OneToOneField or ForeignKey, take it's first arg
+    if model._meta.pk.__class__.__name__ in ["OneToOneField", "ForeignKey"]:
+        if model._meta.pk.rel.to == model:
+            pk_field_args += ["'self'"]
+        else:
+            pk_field_args += [model._meta.pk.rel.to._meta.object_name]
+            other_mocks += [model._meta.pk.rel.to]
+    
+    # Perhaps it has a max_length set?
+    if model._meta.pk.max_length:
+        pk_field_kwargs["max_length"] = model._meta.pk.max_length
+    
+    return "%s%s%s = db.mock_model(model_name='%s', db_table='%s', db_tablespace='%s', pk_field_name='%s', pk_field_type=models.%s, pk_field_args=[%s], pk_field_kwargs=%r)" % \
         (
         (
+        "\n".join([create_mock_model(m, indent) for m in other_mocks]+[""]),
+        indent,
         model._meta.object_name,
         model._meta.object_name,
         model._meta.db_table,
         model._meta.db_tablespace,
         model._meta.pk.name,
         model._meta.object_name,
         model._meta.object_name,
         model._meta.db_table,
         model._meta.db_tablespace,
         model._meta.pk.name,
-        model._meta.pk.__class__.__name__
-        )
\ No newline at end of file
+        model._meta.pk.__class__.__name__,
+        ", ".join(pk_field_args),
+        pk_field_kwargs,
+        )
index 6ffc120..7b160c2 100644 (file)
@@ -1,4 +1,4 @@
-from django.core.management.base import NoArgsCommand
+from django.core.management.base import NoArgsCommand, BaseCommand 
 from django.core.management.color import no_style
 from django.utils.datastructures import SortedDict
 from optparse import make_option
 from django.core.management.color import no_style
 from django.utils.datastructures import SortedDict
 from optparse import make_option
@@ -15,14 +15,17 @@ def get_app_name(app):
 
 class Command(NoArgsCommand):
     option_list = NoArgsCommand.option_list + (
 
 class Command(NoArgsCommand):
     option_list = NoArgsCommand.option_list + (
-        make_option('--verbosity', action='store', dest='verbosity', default='1',
-            type='choice', choices=['0', '1', '2'],
-            help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'),
         make_option('--noinput', action='store_false', dest='interactive', default=True,
             help='Tells Django to NOT prompt the user for input of any kind.'),
         make_option('--migrate', action='store_true', dest='migrate', default=False,
             help='Tells South to also perform migrations after the sync. Default for during testing, and other internal calls.'),
     )
         make_option('--noinput', action='store_false', dest='interactive', default=True,
             help='Tells Django to NOT prompt the user for input of any kind.'),
         make_option('--migrate', action='store_true', dest='migrate', default=False,
             help='Tells South to also perform migrations after the sync. Default for during testing, and other internal calls.'),
     )
+    if '--verbosity' not in [opt.get_opt_string() for opt in BaseCommand.option_list]:
+        option_list += (
+            make_option('--verbosity', action='store', dest='verbosity', default='1',
+            type='choice', choices=['0', '1', '2'],
+            help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'),
+        )
     help = "Create the database tables for all apps in INSTALLED_APPS whose tables haven't already been created, except those which use migrations."
 
     def handle_noargs(self, **options):
     help = "Create the database tables for all apps in INSTALLED_APPS whose tables haven't already been created, except those which use migrations."
 
     def handle_noargs(self, **options):
@@ -37,8 +40,10 @@ class Command(NoArgsCommand):
             else:
                 # This is a migrated app, leave it
                 apps_migrated.append(app_name)
             else:
                 # This is a migrated app, leave it
                 apps_migrated.append(app_name)
+        verbosity = int(options.get('verbosity', 0))
         # Run syncdb on only the ones needed
         # Run syncdb on only the ones needed
-        print "Syncing..."
+        if verbosity > 0:
+            print "Syncing..."
         old_installed, settings.INSTALLED_APPS = settings.INSTALLED_APPS, apps_needing_sync
         old_app_store, cache.app_store = cache.app_store, SortedDict([
             (k, v) for (k, v) in cache.app_store.items()
         old_installed, settings.INSTALLED_APPS = settings.INSTALLED_APPS, apps_needing_sync
         old_app_store, cache.app_store = cache.app_store, SortedDict([
             (k, v) for (k, v) in cache.app_store.items()
@@ -49,13 +54,17 @@ class Command(NoArgsCommand):
         cache.app_store = old_app_store
         # Migrate if needed
         if options.get('migrate', True):
         cache.app_store = old_app_store
         # Migrate if needed
         if options.get('migrate', True):
-            print "Migrating..."
-            management.call_command('migrate')
+            if verbosity > 0:
+                print "Migrating..."
+            management.call_command('migrate', **options)
         # Be obvious about what we did
         # Be obvious about what we did
-        print "\nSynced:\n > %s" % "\n > ".join(apps_needing_sync)
+        if verbosity > 0:
+            print "\nSynced:\n > %s" % "\n > ".join(apps_needing_sync)
         
         if options.get('migrate', True):
         
         if options.get('migrate', True):
-            print "\nMigrated:\n - %s" % "\n - ".join(apps_migrated)
+            if verbosity > 0:
+                print "\nMigrated:\n - %s" % "\n - ".join(apps_migrated)
         else:
         else:
-            print "\nNot synced (use migrations):\n - %s" % "\n - ".join(apps_migrated)
-            print "(use ./manage.py migrate to migrate these)"
+            if verbosity > 0:
+                print "\nNot synced (use migrations):\n - %s" % "\n - ".join(apps_migrated)
+                print "(use ./manage.py migrate to migrate these)"
diff --git a/apps/south/management/commands/test.py b/apps/south/management/commands/test.py
new file mode 100644 (file)
index 0000000..eef8f31
--- /dev/null
@@ -0,0 +1,12 @@
+from django.core import management
+from django.core.management.commands import test
+from django.core.management.commands import syncdb
+
+class Command(test.Command):
+    
+    def handle(self, *args, **kwargs):
+        # point at the core syncdb command when creating tests
+        # tests should always be up to date with the most recent model structure
+        management.get_commands()
+        management._commands['syncdb'] = 'django.core'
+        super(Command, self).handle(*args, **kwargs)
\ No newline at end of file
index c650c54..6452442 100644 (file)
@@ -2,8 +2,11 @@
 import datetime
 import os
 import sys
 import datetime
 import os
 import sys
+import traceback
 from django.conf import settings
 from django.db import models
 from django.conf import settings
 from django.db import models
+from django.core.exceptions import ImproperlyConfigured
+from django.core.management import call_command
 from models import MigrationHistory
 from south.db import db
 
 from models import MigrationHistory
 from south.db import db
 
@@ -57,7 +60,7 @@ def get_migration_names(app):
     return sorted([
         filename[:-3]
         for filename in os.listdir(os.path.dirname(app.__file__))
     return sorted([
         filename[:-3]
         for filename in os.listdir(os.path.dirname(app.__file__))
-        if filename.endswith(".py") and filename != "__init__.py"
+        if filename.endswith(".py") and filename != "__init__.py" and not filename.startswith(".")
     ])
 
 
     ])
 
 
@@ -77,7 +80,9 @@ def get_migration(app, name):
         module = __import__(app.__name__ + "." + name, '', '', ['Migration'])
         return module.Migration
     except ImportError:
         module = __import__(app.__name__ + "." + name, '', '', ['Migration'])
         return module.Migration
     except ImportError:
-        raise ValueError("Migration %s:%s does not exist." % (get_app_name(app), name))
+        print " ! Migration %s:%s probably doesn't exist." % (get_app_name(app), name)
+        print " - Traceback:"
+        raise
 
 
 def all_migrations():
 
 
 def all_migrations():
@@ -201,61 +206,116 @@ def needed_before_backwards(tree, app, name, sameapp=True):
     return remove_duplicates(needed)
 
 
     return remove_duplicates(needed)
 
 
-def run_forwards(app, migrations, fake=False, silent=False):
+def run_migrations(toprint, torun, recorder, app, migrations, fake=False, db_dry_run=False, silent=False):
     """
     Runs the specified migrations forwards, in order.
     """
     for migration in migrations:
         app_name = get_app_name(app)
         if not silent:
     """
     Runs the specified migrations forwards, in order.
     """
     for migration in migrations:
         app_name = get_app_name(app)
         if not silent:
-            print " > %s: %s" % (app_name, migration)
+            print toprint % (app_name, migration)
         klass = get_migration(app, migration)
         klass = get_migration(app, migration)
+
         if fake:
             if not silent:
                 print "   (faked)"
         else:
         if fake:
             if not silent:
                 print "   (faked)"
         else:
-            db.start_transaction()
+            
+            # If the database doesn't support running DDL inside a transaction
+            # *cough*MySQL*cough* then do a dry run first.
+            if not db.has_ddl_transactions:
+                db.dry_run = True
+                db.debug, old_debug = False, db.debug
+                try:
+                    getattr(klass(), torun)()
+                except:
+                    traceback.print_exc()
+                    print " ! Error found during dry run of migration! Aborting."
+                    return False
+                db.debug = old_debug
+                db.clear_run_data()
+            
+            db.dry_run = bool(db_dry_run)
+            
+            if db.has_ddl_transactions:
+                db.start_transaction()
             try:
             try:
-                klass().forwards()
+                getattr(klass(), torun)()
                 db.execute_deferred_sql()
             except:
                 db.execute_deferred_sql()
             except:
-                db.rollback_transaction()
-                raise
+                if db.has_ddl_transactions:
+                    db.rollback_transaction()
+                    raise
+                else:
+                    traceback.print_exc()
+                    print " ! Error found during real run of migration! Aborting."
+                    print
+                    print " ! Since you have a database that does not support running"
+                    print " ! schema-altering statements in transactions, we have had to"
+                    print " ! leave it in an interim state between migrations."
+                    if torun == "forwards":
+                        print
+                        print " ! You *might* be able to recover with:"
+                        db.debug = db.dry_run = True
+                        klass().backwards()
+                    print
+                    print " ! The South developers regret this has happened, and would"
+                    print " ! like to gently persuade you to consider a slightly"
+                    print " ! easier-to-deal-with DBMS."
+                    return False
             else:
             else:
-                db.commit_transaction()
+                if db.has_ddl_transactions:
+                    db.commit_transaction()
+
+        if not db_dry_run:
+            # Record us as having done this
+            recorder(app_name, migration)
+
+
+def run_forwards(app, migrations, fake=False, db_dry_run=False, silent=False):
+    """
+    Runs the specified migrations forwards, in order.
+    """
+    
+    def record(app_name, migration):
         # Record us as having done this
         record = MigrationHistory.for_migration(app_name, migration)
         record.applied = datetime.datetime.utcnow()
         record.save()
         # Record us as having done this
         record = MigrationHistory.for_migration(app_name, migration)
         record.applied = datetime.datetime.utcnow()
         record.save()
-
-
-def run_backwards(app, migrations, ignore=[], fake=False, silent=False):
+    
+    return run_migrations(
+        toprint = " > %s: %s",
+        torun = "forwards",
+        recorder = record,
+        app = app,
+        migrations = migrations,
+        fake = fake,
+        db_dry_run = db_dry_run,
+        silent = silent,
+    )
+
+
+def run_backwards(app, migrations, ignore=[], fake=False, db_dry_run=False, silent=False):
     """
     Runs the specified migrations backwards, in order, skipping those
     migrations in 'ignore'.
     """
     """
     Runs the specified migrations backwards, in order, skipping those
     migrations in 'ignore'.
     """
-    for migration in migrations:
-        if migration not in ignore:
-            app_name = get_app_name(app)
-            if not silent:
-                print " < %s: %s" % (app_name, migration)
-            klass = get_migration(app, migration)
-            if fake:
-                if not silent:
-                    print "   (faked)"
-            else:
-                db.start_transaction()
-                try:
-                    klass().backwards()
-                    db.execute_deferred_sql()
-                except:
-                    db.rollback_transaction()
-                    raise
-                else:
-                    db.commit_transaction()
-            # Record us as having not done this
-            record = MigrationHistory.for_migration(app_name, migration)
-            record.delete()
+    
+    def record(app_name, migration):
+        # Record us as having not done this
+        record = MigrationHistory.for_migration(app_name, migration)
+        record.delete()
+    
+    return run_migrations(
+        toprint = " < %s: %s",
+        torun = "backwards",
+        recorder = record,
+        app = app,
+        migrations = [x for x in migrations if x not in ignore],
+        fake = fake,
+        db_dry_run = db_dry_run,
+        silent = silent,
+    )
 
 
 def right_side_of(x, y):
 
 
 def right_side_of(x, y):
@@ -291,7 +351,7 @@ def backwards_problems(tree, backwards, done, silent=False):
     return problems
 
 
     return problems
 
 
-def migrate_app(app, target_name=None, resolve_mode=None, fake=False, yes=False, silent=False):
+def migrate_app(app, target_name=None, resolve_mode=None, fake=False, db_dry_run=False, yes=False, silent=False, load_inital_data=False):
     
     app_name = get_app_name(app)
     
     
     app_name = get_app_name(app)
     
@@ -310,6 +370,12 @@ def migrate_app(app, target_name=None, resolve_mode=None, fake=False, yes=False,
     tree = dependency_tree()
     migrations = get_migration_names(app)
     
     tree = dependency_tree()
     migrations = get_migration_names(app)
     
+    # If there aren't any, quit quizically
+    if not migrations:
+        if not silent:
+            print "? You have no migrations for the '%s' app. You might want some." % app_name
+        return
+    
     if target_name not in migrations and target_name not in ["zero", None]:
         matches = [x for x in migrations if x.startswith(target_name)]
         if len(matches) == 1:
     if target_name not in migrations and target_name not in ["zero", None]:
         matches = [x for x in migrations if x.startswith(target_name)]
         if len(matches) == 1:
@@ -331,7 +397,15 @@ def migrate_app(app, target_name=None, resolve_mode=None, fake=False, yes=False,
             return
     
     # Check there's no strange ones in the database
             return
     
     # Check there's no strange ones in the database
-    ghost_migrations = [m for m in MigrationHistory.objects.filter(applied__isnull = False) if get_app(m.app_name) not in tree or m.migration not in tree[get_app(m.app_name)]]
+    ghost_migrations = []
+    for m in MigrationHistory.objects.filter(applied__isnull = False):
+        try:
+            if get_app(m.app_name) not in tree or m.migration not in tree[get_app(m.app_name)]:
+                ghost_migrations.append(m)
+        except ImproperlyConfigured:
+            pass
+            
+        
     if ghost_migrations:
         if not silent:
             print " ! These migrations are in the database but not on disk:"
     if ghost_migrations:
         if not silent:
             print " ! These migrations are in the database but not on disk:"
@@ -361,7 +435,12 @@ def migrate_app(app, target_name=None, resolve_mode=None, fake=False, yes=False,
             backwards = []
     
     # Get the list of currently applied migrations from the db
             backwards = []
     
     # Get the list of currently applied migrations from the db
-    current_migrations = [(get_app(m.app_name), m.migration) for m in  MigrationHistory.objects.filter(applied__isnull = False)]
+    current_migrations = []
+    for m in MigrationHistory.objects.filter(applied__isnull = False):
+        try:
+            current_migrations.append((get_app(m.app_name), m.migration))
+        except ImproperlyConfigured:
+            pass
     
     direction = None
     bad = False
     
     direction = None
     bad = False
@@ -416,15 +495,34 @@ def migrate_app(app, target_name=None, resolve_mode=None, fake=False, yes=False,
     if direction == 1:
         if not silent:
             print " - Migrating forwards to %s." % target_name
     if direction == 1:
         if not silent:
             print " - Migrating forwards to %s." % target_name
-        for mapp, mname in forwards:
-            if (mapp, mname) not in current_migrations:
-                run_forwards(mapp, [mname], fake=fake, silent=silent)
+        try:
+            for mapp, mname in forwards:
+                if (mapp, mname) not in current_migrations:
+                    result = run_forwards(mapp, [mname], fake=fake, db_dry_run=db_dry_run, silent=silent)
+                    if result is False: # The migrations errored, but nicely.
+                        return
+        finally:
+            # Call any pending post_syncdb signals
+            db.send_pending_create_signals()
+        # Now load initial data, only if we're really doing things and ended up at current
+        if not fake and not db_dry_run and load_inital_data and target_name == migrations[-1]:
+            print " - Loading initial data for %s." % app_name
+            # Override Django's get_apps call temporarily to only load from the
+            # current app
+            old_get_apps, models.get_apps = (
+                models.get_apps,
+                lambda: [models.get_app(get_app_name(app))],
+            )
+            # Load the initial fixture
+            call_command('loaddata', 'initial_data', verbosity=1)
+            # Un-override
+            models.get_apps = old_get_apps
     elif direction == -1:
         if not silent:
             print " - Migrating backwards to just after %s." % target_name
         for mapp, mname in backwards:
             if (mapp, mname) in current_migrations:
     elif direction == -1:
         if not silent:
             print " - Migrating backwards to just after %s." % target_name
         for mapp, mname in backwards:
             if (mapp, mname) in current_migrations:
-                run_backwards(mapp, [mname], fake=fake, silent=silent)
+                run_backwards(mapp, [mname], fake=fake, db_dry_run=db_dry_run, silent=silent)
     else:
         if not silent:
     else:
         if not silent:
-            print "- Nothing to migrate."
\ No newline at end of file
+            print "- Nothing to migrate."
diff --git a/apps/south/setup.py b/apps/south/setup.py
new file mode 100755 (executable)
index 0000000..9e09583
--- /dev/null
@@ -0,0 +1,26 @@
+#!/usr/bin/python
+
+from setuptools import setup, find_packages
+
+setup(
+    name='South',
+    version='0.4',
+    description='South: Migrations for Django',
+    long_description='South is an intelligent database migrations library for the Django web framework. It is database-independent and DVCS-friendly, as well as a whole host of other features.',
+    author='Andrew Godwin & Andy McCurdy',
+    author_email='south@aeracode.org',
+    url='http://south.aeracode.org/',
+    download_url='http://south.aeracode.org/wiki/Download',
+    classifiers=[
+        "Development Status :: 5 - Production/Stable",
+        "Framework :: Django",
+        "Intended Audience :: Developers",
+        "Intended Audience :: System Administrators",
+        "Intended Audience :: System Administrators",
+        "License :: OSI Approved :: Apache Software License",
+        "Operating System :: OS Independent",
+        "Topic :: Software Development"
+    ],
+    packages=["south", "south.db", "south.management", "south.management.commands", "south.tests", "south.tests.fakeapp", "south.tests.fakeapp.migrations"],
+    package_dir = {"south" : ""},
+)
index c47f021..b7bb145 100644 (file)
@@ -21,6 +21,7 @@ class TestOperations(unittest.TestCase):
 
     def setUp(self):
         db.debug = False
 
     def setUp(self):
         db.debug = False
+        db.clear_deferred_sql()
 
     def test_create(self):
         """
 
     def test_create(self):
         """
@@ -45,7 +46,7 @@ class TestOperations(unittest.TestCase):
         db.rollback_transaction()
         db.start_transaction()
         # Remove the table
         db.rollback_transaction()
         db.start_transaction()
         # Remove the table
-        db.delete_table("test1")
+        db.drop_table("test1")
         # Make sure it went
         try:
             cursor.execute("SELECT * FROM test1")
         # Make sure it went
         try:
             cursor.execute("SELECT * FROM test1")
@@ -63,6 +64,23 @@ class TestOperations(unittest.TestCase):
             pass
         db.rollback_transaction()
     
             pass
         db.rollback_transaction()
     
+    def test_foreign_keys(self):
+        """
+        Tests foreign key creation, especially uppercase (see #61)
+        """
+        Test = db.mock_model(model_name='Test', db_table='test5a',
+                             db_tablespace='', pk_field_name='ID',
+                             pk_field_type=models.AutoField, pk_field_args=[])
+        cursor = connection.cursor()
+        db.start_transaction()
+        db.create_table("test5a", [('ID', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True))])
+        db.create_table("test5b", [
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('UNIQUE', models.ForeignKey(Test)),
+        ])
+        db.execute_deferred_sql()
+        db.rollback_transaction()
+    
     def test_rename(self):
         """
         Test column renaming
     def test_rename(self):
         """
         Test column renaming
@@ -80,4 +98,88 @@ class TestOperations(unittest.TestCase):
             self.fail("Just-renamed column could be selected!")
         except:
             pass
             self.fail("Just-renamed column could be selected!")
         except:
             pass
-        db.rollback_transaction()
\ No newline at end of file
+        db.rollback_transaction()
+        db.delete_table("test2")
+    
+    def test_dry_rename(self):
+        """
+        Test column renaming while --dry-run is turned on (should do nothing)
+        See ticket #65
+        """
+        cursor = connection.cursor()
+        db.create_table("test2", [('spam', models.BooleanField(default=False))])
+        db.start_transaction()
+        # Make sure we can select the column
+        cursor.execute("SELECT spam FROM test2")
+        # Rename it
+        db.dry_run = True
+        db.rename_column("test2", "spam", "eggs")
+        db.dry_run = False
+        cursor.execute("SELECT spam FROM test2")
+        try:
+            cursor.execute("SELECT eggs FROM test2")
+            self.fail("Dry-renamed new column could be selected!")
+        except:
+            pass
+        db.rollback_transaction()
+        db.delete_table("test2")
+    
+    def test_table_rename(self):
+        """
+        Test column renaming
+        """
+        cursor = connection.cursor()
+        db.create_table("testtr", [('spam', models.BooleanField(default=False))])
+        db.start_transaction()
+        # Make sure we can select the column
+        cursor.execute("SELECT spam FROM testtr")
+        # Rename it
+        db.rename_table("testtr", "testtr2")
+        cursor.execute("SELECT spam FROM testtr2")
+        try:
+            cursor.execute("SELECT spam FROM testtr")
+            self.fail("Just-renamed column could be selected!")
+        except:
+            pass
+        db.rollback_transaction()
+        db.delete_table("testtr2")
+    
+    def test_index(self):
+        """
+        Test the index operations
+        """
+        db.create_table("test3", [
+            ('SELECT', models.BooleanField(default=False)),
+            ('eggs', models.IntegerField(unique=True)),
+        ])
+        db.execute_deferred_sql()
+        db.start_transaction()
+        # Add an index on that column
+        db.create_index("test3", ["SELECT"])
+        # Add another index on two columns
+        db.create_index("test3", ["SELECT", "eggs"])
+        # Delete them both
+        db.delete_index("test3", ["SELECT"])
+        db.delete_index("test3", ["SELECT", "eggs"])
+        # Delete the unique index
+        db.delete_index("test3", ["eggs"])
+        db.rollback_transaction()
+        db.delete_table("test3")
+    
+    def test_alter(self):
+        """
+        Test altering columns/tables
+        """
+        db.create_table("test4", [
+            ('spam', models.BooleanField(default=False)),
+            ('eggs', models.IntegerField()),
+        ])
+        db.start_transaction()
+        # Add a column
+        db.add_column("test4", "add1", models.IntegerField(default=3), keep_default=False)
+        # Add a FK with keep_default=False (#69)
+        User = db.mock_model(model_name='User', db_table='auth_user', db_tablespace='', pk_field_name='id', pk_field_type=models.AutoField, pk_field_args=[], pk_field_kwargs={})
+        db.add_column("test4", "user", models.ForeignKey(User), keep_default=False)
+        
+        db.rollback_transaction()
+        db.delete_table("test4")
\ No newline at end of file
diff --git a/apps/south/tests/fakeapp/migrations/0003_alter_spam.py b/apps/south/tests/fakeapp/migrations/0003_alter_spam.py
new file mode 100644 (file)
index 0000000..3a9aea4
--- /dev/null
@@ -0,0 +1,12 @@
+from south.db import db
+from django.db import models
+
+class Migration:
+    
+    def forwards(self):
+        
+        db.alter_column("southtest_spam", 'name', models.CharField(max_length=255, null=True))
+    
+    def backwards(self):
+        
+        db.alter_column("southtest_spam", 'name', models.CharField(max_length=255))
index dfb441e..862c52d 100644 (file)
@@ -117,7 +117,7 @@ class TestMigrationLogic(unittest.TestCase):
         app = self.create_test_app()
         
         self.assertEqual(
         app = self.create_test_app()
         
         self.assertEqual(
-            ["0001_spam", "0002_eggs"],
+            ["0001_spam", "0002_eggs", "0003_alter_spam"],
             migration.get_migration_names(app),
         )
     
             migration.get_migration_names(app),
         )
     
@@ -129,9 +129,10 @@ class TestMigrationLogic(unittest.TestCase):
         # Can't use vanilla import, modules beginning with numbers aren't in grammar
         M1 = __import__("fakeapp.migrations.0001_spam", {}, {}, ['Migration']).Migration
         M2 = __import__("fakeapp.migrations.0002_eggs", {}, {}, ['Migration']).Migration
         # Can't use vanilla import, modules beginning with numbers aren't in grammar
         M1 = __import__("fakeapp.migrations.0001_spam", {}, {}, ['Migration']).Migration
         M2 = __import__("fakeapp.migrations.0002_eggs", {}, {}, ['Migration']).Migration
+        M3 = __import__("fakeapp.migrations.0003_alter_spam", {}, {}, ['Migration']).Migration
         
         self.assertEqual(
         
         self.assertEqual(
-            [M1, M2],
+            [M1, M2, M3],
             list(migration.get_migration_classes(app)),
         )
     
             list(migration.get_migration_classes(app)),
         )
     
@@ -147,7 +148,7 @@ class TestMigrationLogic(unittest.TestCase):
         self.assertEqual(M1, migration.get_migration(app, "0001_spam"))
         self.assertEqual(M2, migration.get_migration(app, "0002_eggs"))
         
         self.assertEqual(M1, migration.get_migration(app, "0001_spam"))
         self.assertEqual(M2, migration.get_migration(app, "0002_eggs"))
         
-        self.assertRaises(ValueError, migration.get_migration, app, "0001_jam")
+        self.assertRaises((ImportError, ValueError), migration.get_migration, app, "0001_jam")
     
     
     def test_all_migrations(self):
     
     
     def test_all_migrations(self):
@@ -158,6 +159,7 @@ class TestMigrationLogic(unittest.TestCase):
             {app: {
                 "0001_spam": migration.get_migration(app, "0001_spam"),
                 "0002_eggs": migration.get_migration(app, "0002_eggs"),
             {app: {
                 "0001_spam": migration.get_migration(app, "0001_spam"),
                 "0002_eggs": migration.get_migration(app, "0002_eggs"),
+                "0003_alter_spam": migration.get_migration(app, "0003_alter_spam"),
             }},
             migration.all_migrations(),
         )
             }},
             migration.all_migrations(),
         )
@@ -186,6 +188,7 @@ class TestMigrationLogic(unittest.TestCase):
             (
                 (u"fakeapp", u"0001_spam"),
                 (u"fakeapp", u"0002_eggs"),
             (
                 (u"fakeapp", u"0001_spam"),
                 (u"fakeapp", u"0002_eggs"),
+                (u"fakeapp", u"0003_alter_spam"),
             ),
             migration.MigrationHistory.objects.values_list("app_name", "migration"),
         )
             ),
             migration.MigrationHistory.objects.values_list("app_name", "migration"),
         )
@@ -241,13 +244,49 @@ class TestMigrationLogic(unittest.TestCase):
             (
                 (u"fakeapp", u"0001_spam"),
                 (u"fakeapp", u"0002_eggs"),
             (
                 (u"fakeapp", u"0001_spam"),
                 (u"fakeapp", u"0002_eggs"),
+                (u"fakeapp", u"0003_alter_spam"),
             ),
             migration.MigrationHistory.objects.values_list("app_name", "migration"),
         )
         
         # Now roll them backwards
             ),
             migration.MigrationHistory.objects.values_list("app_name", "migration"),
         )
         
         # Now roll them backwards
+        migration.migrate_app(app, target_name="0002", resolve_mode=None, fake=False, silent=True)
         migration.migrate_app(app, target_name="0001", resolve_mode=None, fake=True, silent=True)
         migration.migrate_app(app, target_name="zero", resolve_mode=None, fake=False, silent=True)
         
         # Finish with none
         migration.migrate_app(app, target_name="0001", resolve_mode=None, fake=True, silent=True)
         migration.migrate_app(app, target_name="zero", resolve_mode=None, fake=False, silent=True)
         
         # Finish with none
+        self.assertEqual(list(migration.MigrationHistory.objects.all()), [])
+    
+    def test_alter_column_null(self):
+        def null_ok():
+            from django.db import connection, transaction
+            # the DBAPI introspection module fails on postgres NULLs.
+            cursor = connection.cursor()
+            try:
+                cursor.execute("INSERT INTO southtest_spam (id, weight, expires, name) VALUES (100, 10.1, now(), NULL);")
+            except:
+                transaction.rollback()
+                return False
+            else:
+                cursor.execute("DELETE FROM southtest_spam")
+                transaction.commit()
+                return True
+        
+        app = migration.get_app("fakeapp")
+        self.assertEqual(list(migration.MigrationHistory.objects.all()), [])
+        
+        # by default name is NOT NULL
+        migration.migrate_app(app, target_name="0002", resolve_mode=None, fake=False, silent=True)
+        self.failIf(null_ok())
+        
+        # after 0003, it should be NULL
+        migration.migrate_app(app, target_name="0003", resolve_mode=None, fake=False, silent=True)
+        self.assert_(null_ok())
+
+        # make sure it is NOT NULL again
+        migration.migrate_app(app, target_name="0002", resolve_mode=None, fake=False, silent=True)
+        self.failIf(null_ok(), 'name not null after migration')
+        
+        # finish with no migrations, otherwise other tests fail...
+        migration.migrate_app(app, target_name="zero", resolve_mode=None, fake=False, silent=True)
         self.assertEqual(list(migration.MigrationHistory.objects.all()), [])
\ No newline at end of file
         self.assertEqual(list(migration.MigrationHistory.objects.all()), [])
\ No newline at end of file
index f4805b9..d94ebe9 100644 (file)
                     <a href="{{ tag.get_absolute_url }}">{{ tag }}</a>
                     {% endfor %}
                 </li>
                     <a href="{{ tag.get_absolute_url }}">{{ tag }}</a>
                     {% endfor %}
                 </li>
+            </ul>
+            <h2>W innych miejscach</h2>
+            <ul>
                 <li><a href="{{ extra_info.about }}">Lektura na wiki projektu</a></li>
                 <li><a href="{{ extra_info.source_url }}">Lektura w CBN Polona</a></li>
                 <li><a href="{{ extra_info.about }}">Lektura na wiki projektu</a></li>
                 <li><a href="{{ extra_info.source_url }}">Lektura w CBN Polona</a></li>
-                <li><a href="{{ book.xml_file.url }}">Kod źródłowy utworu (XML)</a></li>
+                {% if book.gazeta_link %}
+                <li><a href="{{ book.gazeta_link }}">Omówienie lektury w Lektury.Gazeta.pl</a></li>
+                {% endif %}
             </ul>
         </div>
         <div id="themes-list">
             </ul>
         </div>
         <div id="themes-list">
index 5bcab78..f2e3c24 100644 (file)
@@ -30,6 +30,9 @@
                 Pobierz wszystkie książki z tej półki
             </a>
         {% endif %}
                 Pobierz wszystkie książki z tej półki
             </a>
         {% endif %}
+        {% if last_tag.gazeta_link %}
+            <p><a href="{{ last_tag.gazeta_link }}">Przeczytaj omówienia lektur w Lektury.Gazeta.pl</a></p>
+        {% endif %}
         <ol>
         {% for book in object_list %}
             <li>
         <ol>
         {% for book in object_list %}
             <li>