
import asm3.additional
import asm3.al
import asm3.animal
import asm3.asynctask
import asm3.configuration
import asm3.dbfs
import asm3.i18n
import asm3.media
import asm3.movement
import asm3.utils
import asm3.wordprocessor
from asm3.sitedefs import MULTIPLE_DATABASES_PUBLISH_DIR, MULTIPLE_DATABASES_PUBLISH_FTP, SERVICE_URL

import ftplib
import glob
import os
import shutil
import sys
import tempfile
import threading

def quietcallback(x):
    """ ftplib callback that does nothing instead of dumping to stdout """
    pass

def get_animal_data(dbo, pc=None, animalid=0, include_additional_fields=False, recalc_age_groups=True, strip_personal_data=False, publisher_key="", limit=0):
    """
    Returns a resultset containing the animal info for the criteria given.
    pc: The publish criteria (if None, default is used)
    animalid: If non-zero only returns the animal given (if it is adoptable)
    include_additional_fields: Load additional fields for each result
    strip_personal_data: Remove any personal data such as surrenderer, brought in by, etc.
    publisher_key: The publisher calling this function
    limit: Only return limit rows.
    """
    if pc is None:
        pc = PublishCriteria(asm3.configuration.publisher_presets(dbo))
    
    sql = get_animal_data_query(dbo, pc, animalid, publisher_key=publisher_key)
    rows = dbo.query(sql, distincton="ID")
    asm3.al.debug("get_animal_data_query returned %d rows" % len(rows), "publishers.base.get_animal_data", dbo)

    # If the sheltercode format has a slash in it, convert it to prevent
    # creating images with broken paths.
    if len(rows) > 0 and rows[0]["SHELTERCODE"].find("/") != -1:
        asm3.al.debug("discovered forward slashes in code, repairing", "publishers.base.get_animal_data", dbo)
        for r in rows:
            r.SHORTCODE = r.SHORTCODE.replace("/", "-").replace(" ", "")
            r.SHELTERCODE = r.SHELTERCODE.replace("/", "-").replace(" ", "")

    # If we're using animal comments, override the websitemedianotes field
    # with animalcomments for compatibility with service users and other
    # third parties who were used to the old way of doing things
    if asm3.configuration.publisher_use_comments(dbo):
        for r in rows:
            r.WEBSITEMEDIANOTES = r.ANIMALCOMMENTS

    # If we aren't including animals with blank descriptions, remove them now
    # (but don't let it override the courtesy flag, which should always make animals appear)
    if not pc.includeWithoutDescription:
        oldcount = len(rows)
        rows = [r for r in rows if r.ISCOURTESY == 1 or asm3.utils.nulltostr(r.WEBSITEMEDIANOTES).strip() != "" ]
        asm3.al.debug("removed %d rows without descriptions" % (oldcount - len(rows)), "publishers.base.get_animal_data", dbo)

    # Embellish additional fields if requested
    if include_additional_fields:
        asm3.additional.append_to_results(dbo, rows, "animal")

    # Strip any personal data if requested
    if strip_personal_data:
        for r in rows:
            for k in r.keys():
                if k.startswith("ORIGINALOWNER") or k.startswith("BROUGHTINBY") or k.startswith("CURRENTOWNER") or k.startswith("RESERVEDOWNER"):
                    r[k] = ""

    # Recalculate age groups
    if recalc_age_groups:
        asm3.animal.calc_age_group_rows(dbo, rows)

    # If bondedAsSingle is on, go through the the set of animals and merge
    # the bonded animals into a single record
    def merge_animal(a, aid):
        """
        Find the animal in rows with animalid, merge it into a and
        then remove it from the set.
        """
        for r in rows:
            if r.ID == aid:
                a.ANIMALNAME = "%s / %s" % (a.ANIMALNAME, r.ANIMALNAME)
                r.REMOVE = True # Flag this row for removal
                asm3.al.debug("merged animal %d into %d" % (aid, a.ID), "publishers.base.get_animal_data", dbo)
                break
    
    def check_bonding(r):
        """ Verifies if this row is bonded to another animal and handles 
            the merge. Returns TRUE if this row should be added to the set """
        if "REMOVE" in r and r.REMOVE: 
            return False
        if r.BONDEDANIMALID is not None and r.BONDEDANIMALID != 0:
            merge_animal(r, r.BONDEDANIMALID)
        if r.BONDEDANIMAL2ID is not None and r.BONDEDANIMAL2ID != 0:
            merge_animal(r, r.BONDEDANIMAL2ID)
        return True

    if pc.bondedAsSingle:
        # Sort the list by the Animal ID so that the first entered bonded animal
        # always "wins" and becomes the first to be output
        rows = [ r for r in sorted(rows, key=lambda k: k.ID) if check_bonding(r) ]

    # If animalid was set, only return that row or an empty set if it wasn't present
    if animalid != 0:
        for r in rows:
            if r.ID == animalid:
                return [ r ]
        return []

    # Ordering
    if pc.order == 0:
        rows = sorted(rows, key=lambda k: k.MOSTRECENTENTRYDATE)
    elif pc.order == 1:
        rows = list(reversed(sorted(rows, key=lambda k: k.MOSTRECENTENTRYDATE)))
    elif pc.order == 2:
        rows = sorted(rows, key=lambda k: k.ANIMALNAME)
    else:
        rows = sorted(rows, key=lambda k: k.MOSTRECENTENTRYDATE)

    # If a limit was set, throw away extra rows
    # (we do it here instead of a LIMIT clause as there's extra logic that throws
    #  away rows above).
    if limit > 0 and len(rows) > limit:
        rows = rows[0:limit]

    return rows

def get_animal_data_query(dbo, pc, animalid=0, publisher_key=""):
    """
    Generate the adoptable animal query.
    publisher_key is used to generate an exclusion to remove animals who have 
        a flag called "Exclude from publisher_key" - this prevents animals
        eg: being sent to PetFinder (Exclude from petfinder)
    """
    sql = asm3.animal.get_animal_query(dbo)
    # Always include non-dead courtesy listings
    sql += " WHERE (a.DeceasedDate Is Null AND a.IsCourtesy = 1) OR (a.ID > 0"
    if animalid != 0:
        sql += " AND a.ID = " + str(animalid)
    if not pc.includeCaseAnimals: 
        sql += " AND a.CrueltyCase = 0"
    if not pc.includeNonNeutered:
        sql += " AND (a.Neutered = 1 OR a.SpeciesID NOT IN (%s))" % asm3.configuration.alert_species_neuter(dbo)
    if not pc.includeWithoutImage: 
        sql += " AND EXISTS(SELECT ID FROM media WHERE WebsitePhoto = 1 AND ExcludeFromPublish = 0 AND LinkID = a.ID AND LinkTypeID = 0)"
    if not pc.includeReservedAnimals: 
        sql += " AND a.HasActiveReserve = 0"
    if not pc.includeHold: 
        sql += " AND (a.IsHold = 0 OR a.IsHold Is Null)"
    if not pc.includeQuarantine:
        sql += " AND (a.IsQuarantine = 0 OR a.IsQuarantine Is Null)"
    if not pc.includeTrial:
        sql += " AND a.HasTrialAdoption = 0"
    if publisher_key != "":
        sql += " AND LOWER(a.AdditionalFlags) NOT LIKE LOWER('%%Exclude from %s|%%')" % publisher_key
    # Make sure animal is old enough
    sql += " AND a.DateOfBirth <= " + dbo.sql_value(dbo.today(offset = pc.excludeUnderWeeks * -7))
    # Filter out dead and unadoptable animals
    sql += " AND a.DeceasedDate Is Null AND a.IsNotAvailableForAdoption = 0"
    # Filter out permanent fosters
    sql += " AND a.HasPermanentFoster = 0"
    # Filter out animals with a future adoption
    sql += " AND NOT EXISTS(SELECT ID FROM adoption WHERE MovementType = 1 AND AnimalID = a.ID AND MovementDate > %s)" % dbo.sql_value(dbo.today())
    # Build a set of OR clauses based on any movements/locations
    moveor = []
    if len(pc.internalLocations) > 0 and pc.internalLocations[0].strip() != "null" and "".join(pc.internalLocations) != "":
        moveor.append("(a.Archived = 0 AND a.ActiveMovementID = 0 AND a.ShelterLocation IN (%s))" % ",".join(pc.internalLocations))
    else:
        moveor.append("(a.Archived = 0 AND a.ActiveMovementID = 0)")
    if pc.includeRetailerAnimals:
        moveor.append("(a.ActiveMovementType = %d)" % asm3.movement.RETAILER)
    if pc.includeFosterAnimals:
        moveor.append("(a.ActiveMovementType = %d)" % asm3.movement.FOSTER)
    if pc.includeTrial:
        moveor.append("(a.ActiveMovementType = %d AND a.HasTrialAdoption = 1)" % asm3.movement.ADOPTION)
    sql += " AND (" + " OR ".join(moveor) + ")) ORDER BY a.ID"
    return sql

def get_microchip_data(dbo, patterns, publishername, allowintake = True, organisation_email = ""):
    """
    Returns a list of animals with unpublished microchips.
    patterns:      A list of either microchip prefixes or SQL clauses to OR together
                   together in the preamble, eg: [ '977', "a.SmartTag = 1 AND a.SmartTagNumber <> ''" ]
    publishername: The name of the microchip registration publisher, eg: pettracuk
    allowintake:   True if the provider is ok with registering to the shelter's details on intake
    organisation_email: The org email to set for intake animals (if blank, uses asm3.configuration.email())
    """
    movementtypes = asm3.configuration.microchip_register_movements(dbo)

    try:
        rows = dbo.query(get_microchip_data_query(dbo, patterns, publishername, movementtypes, allowintake), distincton="ID")
    except Exception as err:
        asm3.al.error(str(err), "publisher.get_microchip_data", dbo, sys.exc_info())

    organisation = asm3.configuration.organisation(dbo)
    orgaddress = asm3.configuration.organisation_address(dbo)
    orgtown = asm3.configuration.organisation_town(dbo)
    orgcounty = asm3.configuration.organisation_county(dbo)
    orgpostcode = asm3.configuration.organisation_postcode(dbo)
    orgcountry = asm3.configuration.organisation_country(dbo)
    orgtelephone = asm3.configuration.organisation_telephone(dbo)
    email = asm3.configuration.email(dbo)
    if organisation_email != "": email = organisation_email
    extras = []

    for r in rows:
        use_original_owner_info = False
        use_shelter_info = False

        # If this is a non-shelter animal, use the original owner info
        if r.NONSHELTERANIMAL == 1 and r.ORIGINALOWNERNAME is not None and r.ORIGINALOWNERNAME != "":
            use_original_owner_info = True

        # If this is an on-shelter animal with no active movement, use the shelter info
        elif r.ARCHIVED == 0 and r.ACTIVEMOVEMENTID == 0:
            use_shelter_info = True

        # If this is a shelter animal on foster, but register on intake is set and foster is not, use the shelter info
        elif r.ARCHIVED == 0 and r.ACTIVEMOVEMENTTYPE == 2 and movementtypes.find("0") != -1 and movementtypes.find("2") == -1:
            use_shelter_info = True

        # Otherwise, leave CURRENTOWNER* fields as they are for active movement
        if use_original_owner_info:
            r.CURRENTOWNERNAME = r.ORIGINALOWNERNAME
            r.CURRENTOWNERTITLE = r.ORIGINALOWNERTITLE
            r.CURRENTOWNERINITIALS = r.ORIGINALOWNERINITIALS
            r.CURRENTOWNERFORENAMES = r.ORIGINALOWNERFORENAMES
            r.CURRENTOWNERSURNAME = r.ORIGINALOWNERSURNAME
            r.CURRENTOWNERADDRESS = r.ORIGINALOWNERADDRESS
            r.CURRENTOWNERTOWN = r.ORIGINALOWNERTOWN
            r.CURRENTOWNERCOUNTY = r.ORIGINALOWNERCOUNTY
            r.CURRENTOWNERPOSTCODE = r.ORIGINALOWNERPOSTCODE
            r.CURRENTOWNERCOUNTRY = r.ORIGINALOWNERCOUNTRY
            r.CURRENTOWNERCITY = r.ORIGINALOWNERTOWN
            r.CURRENTOWNERSTATE = r.ORIGINALOWNERCOUNTY
            r.CURRENTOWNERZIPCODE = r.ORIGINALOWNERPOSTCODE
            r.CURRENTOWNERHOMETELEPHONE = r.ORIGINALOWNERHOMETELEPHONE
            r.CURRENTOWNERPHONE = r.ORIGINALOWNERHOMETELEPHONE
            r.CURRENTOWNERWORKTELEPHONE = r.ORIGINALOWNERWORKTELEPHONE
            r.CURRENTOWNERMOBILETELEPHONE = r.ORIGINALOWNERMOBILETELEPHONE
            r.CURRENTOWNERCELLPHONE = r.ORIGINALOWNERMOBILETELEPHONE
            r.CURRENTOWNEREMAILADDRESS = r.ORIGINALOWNEREMAILADDRESS

        if use_shelter_info:
            r.CURRENTOWNERNAME = organisation
            r.CURRENTOWNERTITLE = ""
            r.CURRENTOWNERINITIALS = ""
            r.CURRENTOWNERFORENAMES = ""
            r.CURRENTOWNERSURNAME = organisation
            r.CURRENTOWNERADDRESS = orgaddress
            r.CURRENTOWNERTOWN = orgtown
            r.CURRENTOWNERCOUNTY = orgcounty
            r.CURRENTOWNERPOSTCODE = orgpostcode
            r.CURRENTOWNERCOUNTRY = orgcountry
            r.CURRENTOWNERCITY = orgtown
            r.CURRENTOWNERSTATE = orgcounty
            r.CURRENTOWNERZIPCODE = orgpostcode
            r.CURRENTOWNERHOMETELEPHONE = orgtelephone
            r.CURRENTOWNERPHONE = orgtelephone
            r.CURRENTOWNERWORKTELEPHONE = orgtelephone
            r.CURRENTOWNERMOBILETELEPHONE = orgtelephone
            r.CURRENTOWNERCELLPHONE = orgtelephone
            r.CURRENTOWNEREMAILADDRESS = email

        # If this row has IDENTICHIP2NUMBER and IDENTICHIP2DATE populated, clone the 
        # row and move the values to IDENTICHIPNUMBER and IDENTICHIPDATE for publishing
        if r.IDENTICHIP2NUMBER and r.IDENTICHIP2NUMBER != "":
            x = r.copy()
            x.IDENTICHIPNUMBER = x.IDENTICHIP2NUMBER
            x.IDENTICHIPDATE = x.IDENTICHIP2DATE
            extras.append(x)

    return rows + extras

def get_microchip_data_query(dbo, patterns, publishername, movementtypes = "1", allowintake = True):
    """
    Generates a query for unpublished microchips.
    It does this by looking for animals who have microchips matching the pattern where
        they either have an activemovement of a type with a date newer than sent in the published table
        OR they have a datebroughtin with a date newer than sent in the published table and they're a non-shelter animal
        (if intake is selected as movementtype 0)
        OR they have a datebroughtin with a date newer than sent in the published table, they're currently on shelter/not held
    patterns:      A list of either microchip prefixes or SQL clauses to OR
                   together in the preamble, eg: [ '977', "a.SmartTag = 1 AND a.SmartTagNumber <> ''" ]
    publishername: The name of the microchip registration publisher, eg: pettracuk
    movementtypes: An IN clause of movement types to include. 11 can be used for trial adoptions
    """
    pclauses = []
    for p in patterns:
        if len(p) > 0 and p[0] in [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]:
            pclauses.append("(a.IdentichipNumber IS NOT NULL AND a.IdentichipNumber LIKE '%s%%')" % p)
            pclauses.append("(a.Identichip2Number IS NOT NULL AND a.Identichip2Number LIKE '%s%%')" % p)
        else:
            pclauses.append("(%s)" % p)
    trialclause = ""
    if movementtypes.find("11") == -1:
        trialclause = "AND a.HasTrialAdoption = 0"
    intakeclause = ""
    if movementtypes.find("0") != -1 and allowintake:
        # Note: Use of MostRecentEntryDate will pick up returns as well as intake
        intakeclause = "OR (a.NonShelterAnimal = 0 AND a.IsHold = 0 AND a.Archived = 0 AND (a.ActiveMovementID = 0 OR a.ActiveMovementType = 2) " \
            "AND NOT EXISTS(SELECT SentDate FROM animalpublished WHERE PublishedTo = '%(publishername)s' " \
            "AND AnimalID = a.ID AND SentDate >= a.MostRecentEntryDate))" % { "publishername": publishername }
    nonshelterclause = "OR (a.NonShelterAnimal = 1 AND a.OriginalOwnerID Is Not Null AND a.OriginalOwnerID > 0 AND a.IdentichipDate Is Not Null " \
        "AND NOT EXISTS(SELECT SentDate FROM animalpublished WHERE PublishedTo = '%(publishername)s' " \
        "AND AnimalID = a.ID AND SentDate >= a.IdentichipDate))" % { "publishername": publishername }
    where = " WHERE (%(patterns)s) " \
        "AND a.DeceasedDate Is Null " \
        "AND a.Identichipped=1 " \
        "AND (a.IsNotForRegistration Is Null OR a.IsNotForRegistration=0) " \
        "AND (" \
        "(a.ActiveMovementID > 0 AND a.ActiveMovementType > 0 AND a.ActiveMovementType IN (%(movementtypes)s) %(trialclause)s " \
        "AND NOT EXISTS(SELECT SentDate FROM animalpublished WHERE PublishedTo = '%(publishername)s' " \
        "AND AnimalID = a.ID AND SentDate >= %(movementdate)s )) " \
        "%(nonshelterclause)s " \
        "%(intakeclause)s " \
        ")" % { 
            "patterns": " OR ".join(pclauses),
            # Using max of movementdate/movement.lastchanged prevents registration on intake 
            # on the same day preventing adopter registration
            "movementdate": dbo.sql_greatest([ "a.ActiveMovementDate", "am.LastChangedDate" ]), 
            "movementtypes": movementtypes, 
            "intakeclause": intakeclause,
            "nonshelterclause": nonshelterclause,
            "trialclause": trialclause,
            "publishername": publishername }
    sql = asm3.animal.get_animal_query(dbo) + where
    return sql

def get_adoption_status(dbo, a):
    """
    Returns a string representing the animal's current adoption 
    status.
    """
    l = dbo.locale
    if a.ARCHIVED == 0 and a.CRUELTYCASE == 1: return asm3.i18n._("Cruelty Case", l)
    if a.ARCHIVED == 0 and a.ISQUARANTINE == 1: return asm3.i18n._("Quarantine", l)
    if a.ARCHIVED == 0 and a.ISHOLD == 1: return asm3.i18n._("Hold", l)
    if a.ARCHIVED == 0 and a.HASACTIVERESERVE == 1: return asm3.i18n._("Reserved", l)
    if a.ARCHIVED == 0 and a.HASPERMANENTFOSTER == 1: return asm3.i18n._("Permanent Foster", l)
    if is_animal_adoptable(dbo, a): return asm3.i18n._("Adoptable", l)
    return asm3.i18n._("Not for adoption", l)

def is_animal_adoptable(dbo, a):
    """
    Returns true if the animal a is adoptable. This should match exactly the code in common.js / html.is_animal_adoptable
    """
    p = PublishCriteria(asm3.configuration.publisher_presets(dbo))
    if a.ISCOURTESY == 1: return True
    if a.ISNOTAVAILABLEFORADOPTION == 1: return False
    if a.NONSHELTERANIMAL == 1: return False
    if a.DECEASEDDATE is not None: return False
    if a.HASFUTUREADOPTION == 1: return False
    if a.HASPERMANENTFOSTER == 1: return False
    if a.CRUELTYCASE == 1 and not p.includeCaseAnimals: return False
    if a.NEUTERED == 0 and not p.includeNonNeutered and str(a.SPECIESID) in asm3.configuration.alert_species_neuter(dbo).split(","): return False
    if a.HASACTIVERESERVE == 1 and not p.includeReservedAnimals: return False
    if a.ISHOLD == 1 and not p.includeHold: return False
    if a.ISQUARANTINE == 1 and not p.includeQuarantine: return False
    if a.ACTIVEMOVEMENTTYPE == 2 and not p.includeFosterAnimals: return False
    if a.ACTIVEMOVEMENTTYPE == 8 and not p.includeRetailerAnimals: return False
    if a.ACTIVEMOVEMENTTYPE == 1 and a.HASTRIALADOPTION == 1 and not p.includeTrial: return False
    if a.ACTIVEMOVEMENTTYPE == 1 and a.HASTRIALADOPTION == 0: return False
    if a.ACTIVEMOVEMENTTYPE and a.ACTIVEMOVEMENTTYPE >= 3 and a.ACTIVEMOVEMENTTYPE <= 7: return False
    if not p.includeWithoutImage and a.WEBSITEMEDIANAME == "": return False
    if not p.includeWithoutDescription and asm3.configuration.publisher_use_comments(dbo) and a.ANIMALCOMMENTS == "": return False
    if not p.includeWithoutDescription and not asm3.configuration.publisher_use_comments(dbo) and a.WEBSITEMEDIANOTES == "": return False
    if p.excludeUnderWeeks > 0 and asm3.i18n.add_days(a.DATEOFBIRTH, 7 * p.excludeUnderWeeks) > dbo.today(): return False
    if len(p.internalLocations) > 0 and a.ACTIVEMOVEMENTTYPE == 0 and str(a.SHELTERLOCATION) not in p.internalLocations: return False
    return True

class PublishCriteria(object):
    """
    Class containing publishing criteria. Has functions to 
    convert to and from a command line string
    """
    includeCaseAnimals = False
    includeNonNeutered = False
    includeReservedAnimals = False
    includeRetailerAnimals = False
    includeFosterAnimals = False
    includeQuarantine = False
    includeTrial = False
    includeHold = False
    includeWithoutDescription = False
    includeWithoutImage = False
    includeColours = False
    bondedAsSingle = False
    clearExisting = False
    uploadAllImages = False
    uploadDirectly = False
    forceReupload = False
    noImportFile = False # If a 3rd party has a seperate import disable upload
    generateJavascriptDB = False
    thumbnails = False
    thumbnailSize = "70x70"
    checkSocket = False
    order = 1 # 0 = Ascending entry, 1 = Descending entry, 2 = Ascending name
    excludeUnderWeeks = 12
    animalsPerPage = 10
    htmlByChildAdult = False # True if html pages should be prefixed baby/adult_ and split
    childAdultSplit=26 # Number of weeks before an animal is treated as an adult by the child adult publisher
    htmlBySpecies = False # True if html pages should be output with species name and possibly split by age
    htmlByType = False # True if html pages should be output with type name
    outputAdopted = False # True if html publisher should output an adopted.html page
    outputAdoptedDays = 30 # The number of days to go back when considering adopted animals
    outputDeceased = False # True if html publisher should output a deceased.html page
    outputForms = False # True if html publisher should output a forms.html page
    outputRSS = False # True if html publisher should output an rss.xml page
    style = "."
    extension = "html"
    scaleImages = "" # A resize spec or old values of: 1 = None, 2 = 320x200, 3=640x480, 4=800x600, 5=1024x768, 6=300x300, 7=95x95
    internalLocations = [] # List of either location IDs, or LIKE comparisons
    publishDirectory = None # None = use temp directory for publishing
    ignoreLock = False # Force the publisher to run even if another publisher is running

    def get_int(self, s):
        """
        Returns the val portion of key=val as an int
        """
        return asm3.utils.cint(s.split("=")[1])

    def get_str(self, s):
        """
        Returns the val portion of key=val as a string
        """
        return s.split("=")[1]

    def __init__(self, fromstring = ""):
        """
        Initialises the publishing criteria from a string if given
        """
        if fromstring == "": return
        for s in fromstring.split(" "):
            if s == "includecase": self.includeCaseAnimals = True
            if s == "includenonneutered": self.includeNonNeutered = True
            if s == "includereserved": self.includeReservedAnimals = True
            if s == "includeretailer": self.includeRetailerAnimals = True
            if s == "includefosters": self.includeFosterAnimals = True
            if s == "includehold": self.includeHold = True
            if s == "includequarantine": self.includeQuarantine = True
            if s == "includetrial": self.includeTrial = True
            if s == "includewithoutdescription": self.includeWithoutDescription = True
            if s == "includewithoutimage": self.includeWithoutImage = True
            if s == "includecolours": self.includeColours = True
            if s == "bondedassingle": self.bondedAsSingle = True
            if s == "noimportfile": self.noImportFile = True
            if s == "clearexisting": self.clearExisting = True
            if s == "uploadall": self.uploadAllImages = True
            if s == "forcereupload": self.forceReupload = True
            if s == "generatejavascriptdb": self.generateJavascriptDB = True
            if s == "thumbnails": self.thumbnails = True
            if s == "checksocket": self.checkSocket = True
            if s == "uploaddirectly": self.uploadDirectly = True
            if s == "htmlbychildadult": self.htmlByChildAdult = True
            if s == "htmlbyspecies": self.htmlBySpecies = True
            if s == "htmlbytype": self.htmlByType = True
            if s == "outputadopted": self.outputAdopted = True
            if s == "outputdeceased": self.outputDeceased = True
            if s == "outputforms": self.outputForms = True
            if s == "outputrss": self.outputRSS = True
            if s.startswith("outputadopteddays"): self.outputAdoptedDays = self.get_int(s)
            if s.startswith("order"): self.order = self.get_int(s)
            if s.startswith("excludeunder"): self.excludeUnderWeeks = self.get_int(s)
            if s.startswith("animalsperpage"): self.animalsPerPage = self.get_int(s)
            if s.startswith("style"): self.style = self.get_str(s)
            if s.startswith("extension"): self.extension = self.get_str(s)
            if s.startswith("scaleimages"): self.scaleImages = self.get_str(s)
            if s.startswith("thumbnailsize"): self.thumbnailSize = self.get_str(s)
            if s.startswith("includelocations"): self.internalLocations = self.get_str(s).split(",")
            if s.startswith("publishdirectory"): self.publishDirectory = self.get_str(s)
            if s.startswith("childadultsplit"): self.childAdultSplit = self.get_int(s)

    def __str__(self):
        """
        Returns a string representation of the criteria (which corresponds
        exactly to an ASM 2.x command line string to a publisher and is how
        we store the defaults in the database)
        """
        s = ""
        if self.includeCaseAnimals: s += " includecase"
        if self.includeNonNeutered: s += " includenonneutered"
        if self.includeReservedAnimals: s += " includereserved"
        if self.includeRetailerAnimals: s += " includeretailer"
        if self.includeFosterAnimals: s += " includefosters"
        if self.includeHold: s += " includehold"
        if self.includeQuarantine: s += " includequarantine"
        if self.includeTrial: s += " includetrial"
        if self.includeWithoutDescription: s += " includewithoutdescription"
        if self.includeWithoutImage: s += " includewithoutimage"
        if self.includeColours: s += " includecolours"
        if self.bondedAsSingle: s += " bondedassingle"
        if self.noImportFile: s += " noimportfile"
        if self.clearExisting: s += " clearexisting"
        if self.uploadAllImages: s += " uploadall"
        if self.forceReupload: s += " forcereupload"
        if self.generateJavascriptDB: s += " generatejavascriptdb"
        if self.thumbnails: s += " thumbnails"
        if self.checkSocket: s += " checksocket"
        if self.uploadDirectly: s += " uploaddirectly"
        if self.htmlBySpecies: s += " htmlbyspecies"
        if self.htmlByType: s += " htmlbytype"
        if self.htmlByChildAdult: s += " htmlbychildadult"
        if self.outputAdopted: s += " outputadopted"
        if self.outputDeceased: s += " outputdeceased"
        if self.outputForms: s += " outputforms"
        if self.outputRSS: s += " outputrss"
        s += " order=" + str(self.order)
        s += " excludeunder=" + str(self.excludeUnderWeeks)
        s += " animalsperpage=" + str(self.animalsPerPage)
        s += " style=" + str(self.style)
        s += " extension=" + str(self.extension)
        s += " scaleimages=" + str(self.scaleImages)
        s += " thumbnailsize=" + str(self.thumbnailSize)
        s += " childadultsplit=" + str(self.childAdultSplit)
        s += " outputadopteddays=" + str(self.outputAdoptedDays)
        if len(self.internalLocations) > 0: s += " includelocations=" + ",".join(self.internalLocations)
        if self.publishDirectory is not None: s += " publishdirectory=" + self.publishDirectory
        return s.strip()

class AbstractPublisher(threading.Thread):
    """
    Base class for all publishers
    """
    dbo = None
    pc = None
    totalAnimals = 0
    publisherName = ""
    publisherKey = ""
    publishDateTime = None
    successes = 0
    alerts = 0
    publishDir = ""
    tempPublishDir = True
    locale = "en"
    lastError = ""
    logBuffer = []

    def __init__(self, dbo, publishCriteria):
        threading.Thread.__init__(self)
        self.dbo = dbo
        self.locale = asm3.configuration.locale(dbo)
        self.pc = publishCriteria
        self.makePublishDirectory()

    def checkMappedSpecies(self):
        """
        Returns True if all shelter animal species have been mapped for publishers.
        """
        return 0 == self.dbo.query_int("SELECT COUNT(*) FROM species " \
            "WHERE ID IN (SELECT SpeciesID FROM animal WHERE Archived=0) " \
            "AND (PetFinderSpecies Is Null OR PetFinderSpecies = '')")

    def checkMappedBreeds(self):
        """
        Returns True if all shelter animal breeds have been mapped for publishers
        """
        return 0 == self.dbo.query_int("SELECT COUNT(*) FROM breed " + \
            "WHERE ID IN (SELECT BreedID FROM animal WHERE Archived=0 UNION SELECT Breed2ID FROM animal WHERE Archived=0) " \
            "AND (PetFinderBreed Is Null OR PetFinderBreed = '')")

    def checkMappedColours(self):
        """
        Returns True if all shelter animal colours have been mapped for publishers
        """
        return 0 == self.dbo.query_int("SELECT COUNT(*) FROM basecolour " \
            "WHERE ID IN (SELECT BaseColourID FROM animal WHERE Archived=0) AND " \
            "(AdoptAPetColour Is Null OR AdoptAPetColour = '')")

    def csvLine(self, items):
        """
        Takes a list of CSV line items and returns them as a comma 
        separated string, appropriately quoted and escaped.
        If any items are quoted, the quoting is removed before doing any escaping.
        """
        l = []
        for i in items:
            if i is None: i = ""
            # Remove start/end quotes if present
            if i.startswith("\""): i = i[1:]
            if i.endswith("\""): i = i[0:-1]
            # Escape any quotes in the value
            i = i.replace("\"", "\"\"")
            # Add quoting
            l.append("\"%s\"" % i)
        return ",".join(l)

    def getPhotoUrls(self, animalid):
        """
        Returns a list of photo URLs for animalid. The preferred is always first.
        """
        photo_urls = []
        photos = self.dbo.query("SELECT MediaName FROM media " \
            "WHERE LinkTypeID = 0 AND LinkID = ? AND MediaMimeType = 'image/jpeg' " \
            "AND (ExcludeFromPublish = 0 OR ExcludeFromPublish Is Null) " \
            "ORDER BY WebsitePhoto DESC, ID", [animalid])
        for m in photos:
            photo_urls.append("%s?account=%s&method=dbfs_image&title=%s" % (SERVICE_URL, self.dbo.database, m.MEDIANAME))
        return photo_urls

    def getPublisherBreed(self, an, b1or2 = 1):
        """
        Encapsulates logic for reading publisher breed fields.
        an: The animal row
        b1or2: Whether to get breed 1 or 2
        return value is the publisher breed for datafiles/posts. It can be a blank
        based on whether the animal is a crossbreed or not.
        """
        crossbreed = an["CROSSBREED"]
        breed1id = an["BREEDID"]
        breed2id = an["BREED2ID"]
        breedname = an["BREEDNAME1"]
        publisherbreed = an["PETFINDERBREED"]
        # We're dealing with the first breed field. Always send the mapped
        # publisher breed if it isn't a crossbreed animal
        if b1or2 == 1:
            if crossbreed == 0: 
                return publisherbreed
        # We're dealing with the second breed field. Always send that as a blank
        # if this isn't a crossbreed animal
        elif b1or2 == 2:
            breedname = an["BREEDNAME2"]
            publisherbreed = an["PETFINDERBREED2"]
            if crossbreed == 0: 
                return ""
        # If one of our magic words is found, or both breeds are the
        # same, return a blank. By the time we get here, crossbreed must == 1
        b = asm3.utils.nulltostr(breedname).lower()
        if b == "mix" or b == "cross" or b == "unknown" or b == "crossbreed" or breed1id == breed2id:
            return ""
        # Don't return null
        if publisherbreed is None:
            return ""
        return publisherbreed

    def isPublisherExecuting(self):
        """
        Returns True if a publisher is already currently running against
        this database. If the ignoreLock publishCriteria option has been
        set, always returns false.
        """
        if self.pc.ignoreLock: return False
        return asm3.asynctask.is_task_running(self.dbo)

    def updatePublisherProgress(self, progress):
        """
        Updates the publisher progress in the database
        """
        asm3.asynctask.set_task_name(self.dbo, self.publisherName)
        asm3.asynctask.set_progress_max(self.dbo, 100)
        asm3.asynctask.set_progress_value(self.dbo, progress)

    def replaceMDBTokens(self, dbo, s):
        """
        Replace MULTIPLE_DATABASE tokens in the string given.
        """
        s = s.replace("{alias}", dbo.alias)
        s = s.replace("{database}", dbo.database)
        s = s.replace("{username}", dbo.username)
        return s

    def replaceAnimalTags(self, a, s):
        """
        Replace any $$Tag$$ tags in s, using animal a
        """
        tags = asm3.wordprocessor.animal_tags_publisher(self.dbo, a)
        return asm3.wordprocessor.substitute_tags(s, tags, True, "$$", "$$")

    def resetPublisherProgress(self):
        """
        Resets the publisher progress and stops blocking for other 
        publishers
        """
        asm3.asynctask.reset(self.dbo)

    def setPublisherComplete(self):
        """
        Mark the current publisher as complete
        """
        asm3.asynctask.set_progress_value(self.dbo, 100)

    def getProgress(self, i, n):
        """
        Returns a progress percentage
        i: Current position
        n: Total elements
        """
        return int((float(i) / float(n)) * 100)

    def shouldStopPublishing(self):
        """
        Returns True if we need to stop publishing
        """
        return asm3.asynctask.get_cancel(self.dbo)

    def setStartPublishing(self):
        """
        Clears the stop publishing flag so we can carry on publishing.
        """
        asm3.asynctask.set_cancel(self.dbo, False)

    def setLastError(self, msg):
        """
        Sets the last error message and clears the publisher lock
        """
        asm3.asynctask.set_last_error(self.dbo, msg)
        self.lastError = msg
        if msg != "": self.logError(self.lastError)
        self.resetPublisherProgress()

    def cleanup(self, save_log=True):
        """
        Call when the publisher has completed to tidy up.
        """
        if save_log: self.saveLog()
        self.setPublisherComplete()

    def makePublishDirectory(self):
        """
        Creates a temporary publish directory if one isn't set, or uses
        the one set in the criteria.
        """
        if self.publisherKey == "html":
            # It's HTML publishing - we have some special rules
            # If the publishing directory has been overridden, set it
            if MULTIPLE_DATABASES_PUBLISH_DIR != "":
                self.publishDir = MULTIPLE_DATABASES_PUBLISH_DIR
                # Replace any tokens
                self.publishDir = self.replaceMDBTokens(self.dbo, self.publishDir)
                self.pc.ignoreLock = True
                # Validate that the directory exists
                if not os.path.exists(self.publishDir):
                    self.setLastError("publishDir does not exist: %s" % self.publishDir)
                    return
                # If they've set the option to reupload animal images, clear down
                # any existing images first
                if self.pc.forceReupload:
                    for f in os.listdir(self.publishDir):
                        if f.lower().endswith(".jpg"):
                            os.unlink(os.path.join(self.publishDir, f))
                # Clear out any existing HTML pages
                for f in os.listdir(self.publishDir):
                    if f.lower().endswith(".html"):
                        os.unlink(os.path.join(self.publishDir, f))
                self.tempPublishDir = False
                return
            if self.pc.publishDirectory is not None and self.pc.publishDirectory.strip() != "":
                # The user has set a target directory for their HTML publishing, use that
                self.publishDir = self.pc.publishDirectory
                # Fix any Windows path backslashes that could have been doubled up
                if self.publishDir.find("\\\\") != -1:
                    self.publishDir = self.publishDir.replace("\\\\", "\\")
                # Validate that the directory exists
                if not os.path.exists(self.publishDir):
                    self.setLastError("publishDir does not exist: %s" % self.publishDir)
                    return
                # If they've set the option to reupload animal images, clear down
                # any existing images first
                if self.pc.forceReupload:
                    for f in os.listdir(self.publishDir):
                        if f.lower().endswith(".jpg"):
                            os.unlink(os.path.join(self.publishDir, f))
                # Clear out any existing HTML pages
                for f in os.listdir(self.publishDir):
                    if f.lower().endswith(".html"):
                        os.unlink(os.path.join(self.publishDir, f))
                self.tempPublishDir = False
                return
        # Use a temporary folder for publishing
        self.tempPublishDir = True
        self.publishDir = tempfile.mkdtemp()

    def deletePublishDirectory(self):
        """
        Removes the publish directory if it was temporary
        """
        if self.tempPublishDir:
            shutil.rmtree(self.publishDir, True)

    def replaceSmartHTMLEntities(self, s):
        """
        Replaces well known "smart" HTML entities with ASCII characters (mainly aimed at smartquotes)
        """
        ENTITIES = {
            "180":  "'", # spacing acute
            "8211": "-", # endash
            "8212": "--", # emdash
            "8216": "'", # left single quote
            "8217": "'", # right single quote
            "8218": ",", # single low quote (comma)
            "8220": "\"", # left double quotes
            "8221": "\"", # right double quotes
            "8222": ",,", # double low quote (comma comma)
            "8226": "*", # bullet
            "8230": "...", # ellipsis
            "8242": "'", # prime (stopwatch)
            "8243": "\"", # double prime,
            "10003": "/", # check
            "10004": "/", # heavy check
            "10005": "x", # multiplication x
            "10006": "x", # heavy multiplication x
            "10007": "x", # ballot x
            "10008": "x"  # heavy ballot x
        }
        for k, v in ENTITIES.items():
            s = s.replace("&#" + k + ";", v)
        return s

    def getLocaleForCountry(self, c):
        """
        Some third party sites only accept a locale in their country field rather than
        a name. This is most common in the US where some shelters have dealings with
        people over the border in Mexico and Canada.
        """
        c2l = {
            "United States of America": "US",
            "United States":            "US",
            "USA":                      "US",
            "Mexico":                   "MX",
            "Canada":                   "CA"
        }
        if c is None or c == "": return "US" # Assume US as this is only really used by US publishers
        if len(c) == 2: return c # Already a country code
        for k in c2l.keys():
            if c.lower() == k.lower():
                return c2l[k]
        return "US" # Fall back to US if no match

    def getDescription(self, an, crToBr = False, crToHE = False, crToLF = True, replaceSmart = False):
        """
        Returns the description/bio for an asm3.animal.
        an: The animal record
        crToBr: Convert line breaks to <br /> tags
        crToHE: Convert line breaks to html entity &#10;
        crToLF: Convert line breaks to LF
        replaceSmart: Replace smart HTML entities (mainly apostrophes and quotes) with regular ASCII
        """
        # Note: WEBSITEMEDIANOTES becomes ANIMALCOMMENTS in get_animal_data when publisher_use_comments is on
        notes = asm3.utils.nulltostr(an["WEBSITEMEDIANOTES"])
        # Add any extra text as long as this isn't a courtesy listing
        if an["ISCOURTESY"] != 1: 
            sig = asm3.configuration.third_party_publisher_sig(self.dbo)
            # If publisher tokens are present, replace them
            if sig.find("$$") != -1: sig = self.replaceAnimalTags(an, sig)
            notes += sig
        # Escape carriage returns
        cr = ""
        if crToBr: cr = "<br />"
        elif crToHE: cr = "&#10;"
        elif crToLF: cr = "\n"
        notes = notes.replace("\r\n", cr)
        notes = notes.replace("\r", cr)
        notes = notes.replace("\n", cr)
        # Smart quotes and apostrophes
        if replaceSmart:
            notes = self.replaceSmartHTMLEntities(notes)
        # Escape speechmarks
        notes = notes.replace("\"", "\"\"")
        return notes

    def getLastPublishedDate(self, animalid):
        """
        Returns the last date animalid was sent to the current publisher
        """
        return self.dbo.query_date("SELECT SentDate FROM animalpublished WHERE AnimalID = ? AND PublishedTo = ?", (animalid, self.publisherKey))

    def markAnimalPublished(self, animalid, datevalue = None, extra = ""):
        """
        Marks an animal published at the current date/time for this publisher
        animalid:    The animal id to update
        extra:       The extra text field to set
        """
        if datevalue is None: datevalue = asm3.i18n.now(self.dbo.timezone)
        self.markAnimalUnpublished(animalid)
        self.dbo.insert("animalpublished", {
            "AnimalID":     animalid,
            "PublishedTo":  self.publisherKey,
            "SentDate":     datevalue,
            "Extra":        extra
        }, generateID=False)

    def markAnimalFirstPublished(self, animalid):
        """
        Marks an animal as published to a special "first" publisher - but only if it 
        hasn't been already. This allows the Publishing History to show not only the last
        but the very first time an animal has been published anywhere and effectively
        the date the animal was first made adoptable.
        """
        FIRST_PUBLISHER = "first"
        if 0 == self.dbo.query_int("SELECT COUNT(SentDate) FROM animalpublished WHERE AnimalID = ? AND PUblishedTo = ?",(animalid, FIRST_PUBLISHER)):
            self.dbo.insert("animalpublished", {
                "AnimalID":     animalid,
                "PublishedTo":  FIRST_PUBLISHER,
                "SentDate":     self.dbo.today()
            }, generateID=False)

    def markAnimalUnpublished(self, animalid):
        """
        Marks an animal as not published for the current publisher
        """
        self.dbo.delete("animalpublished", "AnimalID=%d AND PublishedTo='%s'" % (animalid, self.publisherKey))

    def markAnimalsPublished(self, animals, first=False):
        """
        Marks all animals in the set as published at the current date/time
        for the current publisher.
        first: This is an adoptable animal publisher, mark the animal first published for adoption
        """
        batch = []
        inclause = []
        # build a list of IDs and deduplicate them
        for a in animals:
            inclause.append( str(a["ID"]) )
        inclause = set(inclause)
        # build a batch for inserting animalpublished entries into the table
        # and check/mark animals first published
        for i in inclause:
            batch.append( ( int(i), self.publisherKey, asm3.i18n.now(self.dbo.timezone) ) )
            if first: self.markAnimalFirstPublished(int(i))
        if len(inclause) == 0: return
        self.dbo.execute("DELETE FROM animalpublished WHERE PublishedTo = '%s' AND AnimalID IN (%s)" % (self.publisherKey, ",".join(inclause)))
        self.dbo.execute_many("INSERT INTO animalpublished (AnimalID, PublishedTo, SentDate) VALUES (?,?,?)", batch)

    def markAnimalsPublishFailed(self, animals):
        """
        Marks all animals in the set as published at the current date/time
        for the current publisher but with an extra failure message
        """
        batch = []
        inclause = {}
        # build a list of IDs and deduplicate them
        for a in animals:
            m = ""
            if "FAILMESSAGE" in a:
                m = a["FAILMESSAGE"]
            inclause[str(a["ID"])] = m
        # build a batch for inserting animalpublished entries into the table
        for k, v in inclause.items():
            batch.append( ( int(k), self.publisherKey, asm3.i18n.now(self.dbo.timezone), v ) )
        if len(inclause) == 0: return
        self.dbo.execute("DELETE FROM animalpublished WHERE PublishedTo = '%s' AND AnimalID IN (%s)" % (self.publisherKey, ",".join(inclause)))
        self.dbo.execute_many("INSERT INTO animalpublished (AnimalID, PublishedTo, SentDate, Extra) VALUES (?,?,?,?)", batch)

    def getMatchingAnimals(self, includeAdditionalFields=False):
        a = get_animal_data(self.dbo, self.pc, include_additional_fields=includeAdditionalFields, publisher_key=self.publisherKey)
        self.log("Got %d matching animals for publishing." % len(a))
        return a

    def saveFile(self, path, contents):
        try:
            f = open(path, "w")
            f.write(contents)
            f.flush()
            f.close()
        except Exception as err:
            self.logError(str(err), sys.exc_info())

    def initLog(self, publisherKey, publisherName):
        """
        Initialises the log 
        """
        self.publisherKey = publisherKey
        self.publishDateTime = asm3.i18n.now(self.dbo.timezone)
        self.publisherName = publisherName
        self.logBuffer = []

    def log(self, msg):
        """
        Logs a message
        """
        self.logBuffer.append(msg)

    def logError(self, msg, ie=None):
        """
        Logs a message to our logger and dumps a stacktrace.
        ie = error info object from sys.exc_info() if available
        """
        self.log("ALERT: %s" % msg)
        asm3.al.error(msg, self.publisherName, self.dbo, ie)
        self.alerts += 1

    def logSearch(self, needle):
        """ Does a find on logBuffer """
        return "\n".join(self.logBuffer).find(needle)

    def logSuccess(self, msg):
        """
        Logs a success message to our logger
        """
        self.log("SUCCESS: %s" % msg)
        asm3.al.info(msg, self.publisherName, self.dbo)
        self.successes += 1

    def saveLog(self):
        """
        Saves the log to the publishlog table
        """
        self.dbo.insert("publishlog", {
            "PublishDateTime":      self.publishDateTime,
            "Name":                 self.publisherKey,
            "Success":              self.successes,
            "Alerts":               self.alerts,
            "*LogData":              "\n".join(self.logBuffer)
        })

    def isImage(self, path):
        """
        Returns True if the path given has a valid image extension
        """
        return path.lower().endswith("jpg") or path.lower().endswith("jpeg")

    def generateThumbnail(self, image, thumbnail):
        """
        Generates a thumbnail 
        image: Path to the image to generate a thumbnail from
        thumbnail: Path to the target thumbnail image
        """
        self.log("generating thumbnail %s -> %s" % ( image, thumbnail ))
        try:
            asm3.media.scale_image_file(image, thumbnail, self.pc.thumbnailSize)
        except Exception as err:
            self.logError("Failed scaling thumbnail: %s" % err, sys.exc_info())

    def scaleImage(self, image, scalesize):
        """
        Scales an image. scalesize is the scaleImage publish criteria and
        can either be a resize spec, or it can be one of our old ASM2
        fixed numbers.
        image: The image file
        Empty string = No scaling
        1 = No scaling
        2 = 320x200
        3 = 640x400
        4 = 800x600
        5 = 1024x768
        6 = 300x300
        7 = 95x95
        """
        sizespec = ""
        if scalesize == "" or scalesize == "1": return image
        elif scalesize == "2": sizespec = "320x200"
        elif scalesize == "3": sizespec = "640x400"
        elif scalesize == "4": sizespec = "800x600"
        elif scalesize == "5": sizespec = "1024x768"
        elif scalesize == "6": sizespec = "300x300"
        elif scalesize == "7": sizespec = "95x95"
        else: sizespec = scalesize
        self.log("scaling %s to %s" % ( image, scalesize ))
        try:
            return asm3.media.scale_image_file(image, image, sizespec)
        except Exception as err:
            self.logError("Failed scaling image: %s" % err, sys.exc_info())

class FTPPublisher(AbstractPublisher):
    """
    Base class for publishers that rely on FTP
    """
    socket = None
    ftphost = ""
    ftpuser = ""
    ftppassword = ""
    ftpport = 21
    ftproot = ""
    currentDir = ""
    passive = True
    existingImageList = None

    def __init__(self, dbo, publishCriteria, ftphost, ftpuser, ftppassword, ftpport = 21, ftproot = "", passive = True):
        AbstractPublisher.__init__(self, dbo, publishCriteria)
        self.ftphost = ftphost
        self.ftpuser = ftpuser
        self.ftppassword = self.unxssPass(ftppassword)
        self.ftpport = ftpport
        self.ftproot = ftproot
        self.passive = passive

    def unxssPass(self, s):
        """
        Passwords stored in the config table are subject to XSS escaping, so
        any >, < or & in the password will have been escaped - turn them back again.
        Also, many people copy and paste FTP passwords for PetFinder and AdoptAPet
        and include extra spaces on the end, so strip it.
        """
        s = s.replace("&lt;", "<")
        s = s.replace("&gt;", ">")
        s = s.replace("&amp;", "&")
        s = s.strip()
        return s

    def openFTPSocket(self):
        """
        Opens an FTP socket to the server and changes to the
        root FTP directory. Returns True if all was well or
        uploading is disabled.
        """
        if not self.pc.uploadDirectly: return True
        if self.ftphost.strip() == "": raise ValueError("No FTP host set")
        self.log("Connecting to %s as %s" % (self.ftphost, self.ftpuser))
        
        try:
            # open it and login
            self.socket = ftplib.FTP(host=self.ftphost, timeout=15)
            self.socket.login(self.ftpuser, self.ftppassword)
            self.socket.set_pasv(self.passive)

            if self.ftproot is not None and self.ftproot != "":
                # If we had an FTP override, try and create the directory
                # before we change to it.
                if MULTIPLE_DATABASES_PUBLISH_FTP is not None:
                    self.mkdir(self.ftproot)
                self.chdir(self.ftproot)

            return True
        except Exception as err:
            self.logError("Failed opening FTP socket (%s->%s): %s" % (self.dbo.database, self.ftphost, err), sys.exc_info())
            return False

    def closeFTPSocket(self):
        if not self.pc.uploadDirectly: return
        try:
            self.socket.quit()
        except:
            pass

    def reconnectFTPSocket(self):
        """
        Reconnects to the FTP server, changing back to the current directory.
        """
        self.closeFTPSocket()
        self.openFTPSocket()
        if not self.currentDir == "":
            self.chdir(self.currentDir)

    def checkFTPSocket(self):
        """
        Called before each upload if publishCriteria.checkSocket is
        set to true. It verifies that the socket is still active
        by running a command. If the command fails, the socket is
        reopened and the current FTP directory is returned to.
        """
        if not self.pc.uploadDirectly: return
        try:
            self.socket.retrlines("LIST", quietcallback)
        except Exception as err:
            self.log("Dead socket (%s), reconnecting" % err)
            self.reconnectFTPSocket()

    def upload(self, filename):
        """
        Uploads a file to the current FTP directory. If a full path
        is given, this throws it away and just uses the name with
        the temporary publishing directory.
        """
        if filename.find(os.sep) != -1: filename = filename[filename.rfind(os.sep) + 1:]
        if not self.pc.uploadDirectly: return
        if not os.path.exists(os.path.join(self.publishDir, filename)): return
        self.log("Uploading: %s" % filename)
        try:
            if self.pc.checkSocket: self.checkFTPSocket()
            # Store the file
            f = open(os.path.join(self.publishDir, filename), "rb")
            self.socket.storbinary("STOR %s" % filename, f, callback=quietcallback)
            f.close()
        except Exception as err:
            self.logError("Failed uploading %s: %s" % (filename, err), sys.exc_info())
            self.log("reconnecting FTP socket to reset state")
            self.reconnectFTPSocket()

    def lsdir(self):
        if not self.pc.uploadDirectly: return []
        try:
            return self.socket.nlst()
        except Exception as err:
            self.logError("list: %s" % err)

    def mkdir(self, newdir):
        if not self.pc.uploadDirectly: return
        self.log("FTP mkdir %s" % newdir)
        try:
            self.socket.mkd(newdir)
        except Exception as err:
            self.log("mkdir %s: already exists (%s)" % (newdir, err))

    def chdir(self, newdir, fromroot = ""):
        """ Changes FTP folder. Returns True on success, False for failure """
        if not self.pc.uploadDirectly: return True
        self.log("FTP chdir to %s" % newdir)
        try:
            self.socket.cwd(newdir)
            if fromroot == "":
                self.currentDir = newdir
            else:
                self.currentDir = fromroot
            return True
        except Exception as err:
            self.logError("chdir %s: %s" % (newdir, err), sys.exc_info())
            return False

    def delete(self, filename):
        try:
            self.socket.delete(filename)
        except Exception as err:
            self.log("delete %s: %s" % (filename, err))

    def clearExistingHTML(self):
        try:
            oldfiles = glob.glob(os.path.join(self.publishDir, "*." + self.pc.extension))
            for f in oldfiles:
                os.remove(f)
        except Exception as err:
            self.logError("warning: failed removing %s from filesystem: %s" % (oldfiles, err), sys.exc_info())
        if not self.pc.uploadDirectly: return
        try:
            for f in self.socket.nlst("*.%s" % self.pc.extension):
                if not f.startswith("search"):
                    self.socket.delete(f)
        except Exception as err:
            self.logError("warning: failed deleting from FTP server: %s" % err, sys.exc_info())

    def clearExistingImages(self):
        try:
            oldfiles = glob.glob(os.path.join(self.publishDir, "*.jpg"))
            for f in oldfiles:
                os.remove(f)
        except Exception as err:
            self.logError("warning: failed removing %s from filesystem: %s" % (oldfiles, err), sys.exc_info())
        if not self.pc.uploadDirectly: return
        try:
            for f in self.socket.nlst("*.jpg"):
                self.socket.delete(f)
        except Exception as err:
            self.logError("warning: failed deleting from FTP server: %s" % err, sys.exc_info())

    def cleanup(self, save_log=True):
        """
        Call when the publisher has completed to tidy up.
        """
        self.closeFTPSocket()
        self.deletePublishDirectory()
        if save_log: self.saveLog()
        self.setPublisherComplete()

    def uploadImage(self, a, medianame, imagename):
        """
        Retrieves image with medianame from the DBFS to the publish
        folder and uploads it via FTP with imagename
        """
        try:
            # Check if the image is already on the server if 
            # forceReupload is off and the animal doesn't
            # have any recently changed images
            if not self.pc.forceReupload and a["RECENTLYCHANGEDIMAGES"] == 0:
                if self.existingImageList is None:
                    self.existingImageList = self.lsdir()
                elif imagename in self.existingImageList:
                    self.log("%s: skipping, already on server" % imagename)
                    return
            imagefile = os.path.join(self.publishDir, imagename)
            thumbnail = os.path.join(self.publishDir, "tn_" + imagename)
            asm3.dbfs.get_file(self.dbo, medianame, "", imagefile)
            self.log("Retrieved image: %d::%s::%s" % ( a["ID"], medianame, imagename ))
            # If scaling is on, do it
            if str(self.pc.scaleImages) in ( "2", "3", "4", "5", "6", "7" ) or str(self.pc.scaleImages).find("x") > -1:
                self.scaleImage(imagefile, self.pc.scaleImages)
            # If thumbnails are on, do it
            if self.pc.thumbnails:
                self.generateThumbnail(imagefile, thumbnail)
            # Upload
            if self.pc.uploadDirectly:
                self.upload(imagefile)
                if self.pc.thumbnails:
                    self.upload(thumbnail)
        except Exception as err:
            self.logError("Failed uploading image %s: %s" % (medianame, err), sys.exc_info())
            return 0

    def uploadImages(self, a, copyWithMediaIDAsName = False, limit = 0):
        """
        Uploads all the images for an animal as sheltercode-X.jpg if
        upload all is on, or just sheltercode.jpg if upload all is off.
        If copyWithMediaIDAsName is on, it uploads the preferred
        image again and calls it mediaID.jpg (for compatibility with
        older templates).
        Even if uploadDirectly is off, we still pull the images to the
        publish folder.
        If limit is set to zero and uploadAll is on, all images
        are uploaded. If uploadAll is off, only the preferred
        image is uploaded.
        Images with the ExcludeFromPublish flag set are ignored.
        """
        # The first image is always the preferred
        totalimages = 0
        animalcode = a["SHELTERCODE"]
        animalweb = a["WEBSITEMEDIANAME"]
        if animalweb is None or animalweb == "": return totalimages
        # If we've got HTML entities in our sheltercode, it's going to
        # mess up filenames. Use the animalid instead.
        if animalcode.find("&#") != -1:
            animalcode = str(a["ID"])
        # Name it sheltercode-1.jpg or sheltercode.jpg if uploadall is off
        imagename = animalcode + ".jpg"
        if self.pc.uploadAllImages:
            imagename = animalcode + "-1.jpg"
        # If we're forcing reupload or the animal has
        # some recently changed images, remove all the images
        # for this animal before doing anything.
        if self.pc.forceReupload or a["RECENTLYCHANGEDIMAGES"] > 0:
            if self.existingImageList is None:
                self.existingImageList = self.lsdir()
            for ei in self.existingImageList:
                if ei.startswith(animalcode):
                    self.log("delete: %s" % ei)
                    self.delete(ei)
        # Save it to the publish directory
        totalimages = 1
        self.uploadImage(a, animalweb, imagename)
        # If we're saving a copy with the media ID, do that too
        if copyWithMediaIDAsName:
            self.uploadImage(a, animalweb, animalweb)
        # If upload all is set, we need to grab the rest of
        # the animal's images upto the limit. If the limit is
        # zero, we upload everything.
        if self.pc.uploadAllImages:
            mrecs = asm3.media.get_image_media(self.dbo, asm3.media.ANIMAL, a["ID"], True)
            self.log("Animal has %d media files (%d recently changed)" % (len(mrecs), a["RECENTLYCHANGEDIMAGES"]))
            for m in mrecs:
                # Ignore the main media since we used that
                if m["MEDIANAME"] == animalweb:
                    continue
                # Have we hit our limit?
                if totalimages == limit:
                    return totalimages
                totalimages += 1
                # Get the image
                otherpic = m["MEDIANAME"]
                imagename = "%s-%d.jpg" % ( animalcode, totalimages )
                self.uploadImage(a, otherpic, imagename)
        return totalimages


