"""
A custom renderer for guiding people through enrolling their OAI URLs
with purvowe.

Yes, there are serious parallels here to voidoi.  Not sure whether
that's reason enough to try and unify the code bases.
"""

import datetime
import hashlib
import os
import time
import urllib.parse

from lxml import etree
import requests
from twisted.internet import threads
from twisted.python import threadable  #noflake: twisted instrumentation
from twisted.web import template

from gavo import api
from gavo import formal
from gavo import utils
from gavo import web
from gavo.base import osinter
from gavo.helpers import testtricks
from gavo.utils import stanxml


RD_ID = "purx/q"

# test instrumentation: test suites can add URL/callable pairs
# here, and getRegistryRecord will call them instead of requests.get.
# What's returned must work as a requests.Response object within reason.
TEST_DATA = {}

################### Mail templates used below

_ENROLLMENT_MAIL_TEMPLATE = """From: purx admin <{admin_addr}>
To: {contact_email}
Cc: gavo@ari.uni-heidelberg.de
Subject: Your VO registry record

Hello,

Someone (hopefully you) asked the document at

{source_url}

to be included into the Virtual Observatory Registry via the purx
proxy (see http://dc.g-vo.org/PURX for more information).

Your are listed as the contact person in that record.  If you want to
publish this record, open the URL

{enroll_url}

in a web browser.  Your record should then become visible in the VO
Registry within something like a day.

You can always inspect what purx thinks about your service (in terms
of modification dates, etc.) at

{qp_url}

If you did not expect this mail, apologies; someone must have submitted
a record for which you are the contact to our services.

Thanks.
"""


_FAIL1_MAIL_TEMPLATE = """From: purx admin <{admin_addr}>
To: {contact_email}
Cc: gavo@ari.uni-heidelberg.de
Subject: Your VO resource {ivoid}

Hello,

I've failed several times to re-harvest

{source_url}

in order to update the VO registry record

{ivoid}

we (purx, http://dc.g-vo.org/PURX) publish for you to the VO Registry.

The error message I got is

{errmsg}

Can you fix this? Feel free to contact us if you don't understand
the problem.

If the error persists, we will send another mail a while from now and
then delete the record, assuming the service is unmaintained and down.

Thanks.
"""


_FAIL2_MAIL_TEMPLATE = """From: purx admin <{admin_addr}>
To: {contact_email}
Cc: gavo@ari.uni-heidelberg.de
Subject: Your VO resource {ivoid}

Hello,

A while ago I complained that

{source_url}

is unresponsive or bad.  The information there is needed in order to update the
VO registry record

{ivoid}

While trying to re-havest today, I encountered the following error:

{errmsg}

Since the error has persisted for a while, we will delete the corresponding
registry record we have been publishing for you in a couple of days.
In case you fix the problem later, you will have to re-enroll at
http://dc.g-vo.org/PURX.

Thanks.
"""


################### Enrollment, mainly

class NotModified(utils.ExecutiveAction):
	"""is raised when a record has not been modified against what we believe
	is the last state.
	"""


@utils.memoized
def getAuthority():
	"""returns the authority configured.

	This is the content of a file named AUTHORITY in the resdir.
	"""
	with open(api.getRD(RD_ID).getAbsPath("AUTHORITY")) as f:
		return f.read().strip()


def makeConfirmationKey():
	"""returns a random ASCII-string.
	"""
	# md5 use doesn't matter since we're hashing random stuff only anyway
	with open("/dev/urandom", "rb") as f:
		return hashlib.md5(f.read(8)).hexdigest()
	

def getRegistryRecord(url, lastModified=None):
	"""retrieves url and returns the content as an lxml element tree and
	a unix timestamp for the modification utc (may be None).

	This will unwrap any OAI-PMH envelope that may be present.

	If lastModified is non-None, it is used as a if-modified-since
	timestamp.  If a document has not been modified, a NotModified
	exception will be raised.

	This will raise a ValidationError if the URL cannot be retrieved.
	"""
	headers = {}
	if lastModified:
		headers["if-modified-since"] = utils.formatRFC2616Date(lastModified)

	try:
		if url in TEST_DATA:
			get_function = TEST_DATA[url]
		else:
			get_function = requests.get

		res = get_function(url, headers=headers)
		if res.status_code==304:
			raise NotModified("Remote resource not modified")
		res.raise_for_status()
	except requests.exceptions.BaseHTTPError as ex:
		raise api.ValidationError(
			"Cannot retrieve your document: %s"%repr(ex),
			"url")
	
	try:
		root = etree.XML(res.text)
	except Exception as msg:
		raise api.ui.logOldExc(
			api.ValidationError(
				"XML returned from this URI is not well-formed: %s"%
					utils.safe_str(msg), "url"))

	try:
		if root.tag.endswith("OAI-PMH"):
			root = root.xpath("//ri:Resource",
				namespaces={"ri": "http://www.ivoa.net/xml/RegistryInterface/v1.0"})[0]
	except Exception as msg:
		raise api.ui.logOldExc(
			api.ValidationError(
				"XML returned from this URI is neither OAI-PMH with VOResource"
				" metadata nor a standalond ri:Resource document.  (%s)"%
					utils.safe_str(msg), "url"))

	lm = res.headers.get("last-modified")
	if lm:
		dateModified = utils.parseRFC2616Date(lm)
	else:
		dateModified = int(time.time())
	
	return root, dateModified


def ensureValidVOResource(xmlMaterial):
	"""raises an error if the VOResource is invalid.
	"""
	try:
		errorMsgs = testtricks.getXSDErrors(xmlMaterial)
		if not errorMsgs:
			return
	except Exception as msg:
		errorMsgs = msg
	raise api.ValidationError("Resource record is not valid VOResource."
		"  Error(s): "
		+utils.safe_str(errorMsgs), "url")


def makeIdFor(upstreamId, sourceURL):
	"""returns a suggested ivoid make from upstreamId and sourceURL.

	The id returned is "free" at the time of generation.  Here's what we try
	as path part of the new id (authority is always our authority):

	(1) the local part of upstreamID

	(2) the longest part in the host name in sourceURL/local part

	(3) (2) with /n, n=1...inf appended.

	If this races (i.e., someone else registers the id this comes up with
	before the suggestion is committed to the database), the database
	will catch a primary key violation.  That's not going to be a
	pretty error message, but at least nothing bad happens.
	"""
	authority = getAuthority()

	def mkid(localPart):
		return urllib.parse.urlunparse(
			("ivo", authority, localPart, None, None, None))

	with api.getTableConn() as conn:

		def idExists(id):
			return list(conn.query("select 1 from purx.sources"
				" where ivoid=%(id)s", locals()))

		remotePath = urllib.parse.urlparse(upstreamId).path
		ivoid = mkid(remotePath)
		if not idExists(ivoid):
			return ivoid

		parts = urllib.parse.urlparse(sourceURL).netloc.split(".")
		parts.sort(key=len)
		withHost = parts[-1]+remotePath
		ivoid = mkid(withHost)
		if not idExists(ivoid):
			return ivoid

		for i in range(1, 10):
			ivoid = mkid("%s/%d"%(withHost, i))
			if not idExists(ivoid):
				return ivoid

		raise api.ValidationError("Could not generate a unique ivoid for"
			" this record (perhaps change the local part?)", "url")


def serializeTree(lxmlTree):
	"""returns a utf-8 encoded bytestring of a VOResource etree.

	This function ensures the recommended (well, mandatory, actually)
	prefixes are being used.
	"""
	ns = stanxml.NSRegistry._registry
	# well, of course there are namespace prefixes in attributes (sigh);
	# I collect them under the assumption that xsi:type is the only
	# such pathologic attribute.
	attPrefixes = set()
	for el in lxmlTree.xpath("//@xsi:type", namespaces=ns):
		attPrefixes.add(el.split(":")[0])

	etree.cleanup_namespaces(lxmlTree, keep_ns_prefixes=attPrefixes)
	return etree.tostring(
		lxmlTree,
		xml_declaration=False,
		encoding="utf-8")


def fixIdentifier(parsedRecord, newIdentifier):
	"""replaces the identifier in parsedRecord with newIdentifier.

	The change is performed in place.  For convenience, we still
	return the tree.
	"""
	match = parsedRecord.xpath("identifier")[0]
	match.text = newIdentifier
	return parsedRecord


def getContactEmail(parsedRecord):
	"""returns the contact email given in a parsed VOResource record.

	This will raise a ValidationError if something seems to be wrong
	with the email of if it is not given.
	"""
	match = parsedRecord.xpath("curation/contact/email")
	if not match or "@" not in match[0].text:
		raise api.ValidationError("No or invalid contact email in the OAI record.",
			"url",
			hint="If this record was created by GAVO DaCHS, you need"
				" to set the contact.email meta item either in your RD"
				" or in DaCHS' etc/defaultmeta.txt")
	return match[0].text


def makeDBRecord(sourceURL, parsedRecord, dateModified=None):
	"""generates a record for ingestion into the database.

	This obtains the necessary metadata from parsedRecord (an lxml etree of
	the registry record).  This is also what generates the new ivoid.

	This will also perform valiation on parsedRecord and raise all kinds
	of errors when the record otherwise looks unfit for purx.
	"""
	ensureValidVOResource(parsedRecord)

	upstreamId = parsedRecord.xpath("identifier")[0].text
	ivoid = makeIdFor(upstreamId, sourceURL)

	return {
		"source_url": sourceURL,
		"title": parsedRecord.xpath("title")[0].text,
		"rectimestamp": datetime.datetime.utcnow(),
		"upstream_update": utils.parseISODT(parsedRecord.get("updated")),
		"modification_date": dateModified,
		"ivoid": ivoid,
		"status": "PENDING",
		"access_code": makeConfirmationKey(),
		"contact_email": getContactEmail(parsedRecord),
		"xml_source": serializeTree(
			fixIdentifier(parsedRecord, ivoid)).decode("utf-8"),
	}


def prepareMailing(row):
	"""returns a a mail text for notification and the contact email address.

	row is a purx.sources row.
	"""
	fillers = {
		"enroll_url": api.getRD("purx/q").getById("enroll"
			).getURL("custom")+"/confirm/"+row["access_code"],
		'admin_addr': api.getConfig("maintainerAddress"),
		'qp_url': api.getRD(RD_ID).getById("urlstatus"
			).getURL("qp", absolute=True)+"/"+urllib.parse.quote(
				row["source_url"]),
	}
	fillers.update(row)

	return _ENROLLMENT_MAIL_TEMPLATE.format(**fillers)


def _resuscitateDELETED(conn, row):
	"""helps runEnrollmentFor by manipulating a DELETED record clashing with
	row into being revivable.

	This returns True if row clashed with a deleted record and is now
	set up to be confirmed, False otherwise.  As a side effect, row
	will be changed in place to reflect what is in the database.

	conn must be a writable database connection.

	Technically, this just adds an access_code to row.  Since
	runConfirmationFor doesn't care about which state a row is in,
	the record is then ready for confirmation.

	After confirmation, we fully update the record, so the empty source_xml
	will be fixed then.  What's a bit weird is that the access code is
	essentially valid indefinitely, but that doesn't look like a problem.
	"""
	res = list(conn.queryToDicts("SELECT * FROM purx.sources WHERE"
		" source_url=%(source_url)s AND status='DELETED'", row))
	if not res:
		return False
	
	res[0]["access_code"] = row["access_code"]
	conn.execute("UPDATE purx.sources SET access_code=%(access_code)s"
		" WHERE source_url=%(source_url)s", row)
	row.update(res[0])

	return True


def runEnrollmentFor(url, force=False):
	"""prepares a VOResource record at url for enrollment.

	On success, this returns the database row written.

	The function raises ValidationErrors on a field url as part of its
	API.

	If you pass force, any previous record for url is overwritten.
	"""
	try:
		xmlTree, dateModified = getRegistryRecord(url)
	except requests.HTTPError as ex:
		raise api.ValidationError("Could not retrieve your registry record: %s"%
			utils.safe_str(ex), "url")
	
	row = makeDBRecord(url, xmlTree, dateModified)

	with api.getWritableAdminConn() as conn:
		if force:
			conn.execute("DELETE FROM purx.sources WHERE source_url=%(su)s",
				{"su": url})

		srcTable = api.TableForDef(
			api.getRD(RD_ID).getById("sources"),
			connection=conn)
		try:
			srcTable.addRow(row)
		except api.ValidationError:
			# dbtable already mutates down the IntegrityError, but the
			# message it produces isn't really suitable for the web.
			# So, we hope there's no other reason for the ValidationError.
			#
			# Either way, we want to let people re-animate existing DELETED
			# records, so we check this first.
			conn.rollback()  # clear the error
			if not _resuscitateDELETED(conn, row):
				raise api.ValidationError("Someone else has already submitted this"
					" record for inclusion.  If you don't find a corresponding"
					" mail in your mailbox, contact the operators.", "url")
	return row


def runConfirmationFor(confirmationCode):
	"""executes the action requested by confirmationCode.

	This returns the modified record or None if no operation was pending
	this confirmation code.
	"""
	with api.getWritableAdminConn() as conn:
		res = list(conn.queryToDicts("SELECT * FROM purx.sources WHERE"
			" access_code=%(confirmationCode)s", locals()))
		if not res:
			return None
		rec = res[0]
	
		# if we're coming back from DELETED, update the whole thing so
		# we're filling out source_xml and refreshing whatever might
		# have changed in there.
		if rec["status"]=="DELETED":
			rec["status"] = "OK"
			rec["modification_date"] = None
			rec["upstream_update"] = datetime.datetime(1970, 1, 1)
			updateSource(rec)
		
		else:
			conn.execute("UPDATE purx.sources SET status='OK', access_code=NULL"
				" WHERE source_url=%(source_url)s", rec)

		return next(conn.queryToDicts("SELECT * FROM purx.sources"
			" WHERE source_url=%(source_url)s", rec))


################# Updating/maintenance


class StateMachine(object):
	"""A state machine for changing source records based.

	There are four input symbols: success, not-modified, error, and forbidden.
	At this point I interpret not-modified as success from error states.
	The states are the possible values of the status column.
	(TODO: make sure we and q.rd agree what these are).
	"""
	transitions = {
		('PENDING', 'success'): "nop",
		('PENDING', 'error'): "nop",
		('PENDING', 'forbidden'): "nop",
		('OK', 'success'): "update",
		('OK', 'error'): "enterFAIL1",
		('OK', 'forbidden'): "markDeleted",
		('OK', 'not-modified'): "nop",
		('DELETED', 'success'): "markDeleted",
		('DELETED', 'error'): "markDeleted",
		('DELETED', 'forbidden'): "markDeleted",
		('FAIL5', 'success'): "update",
		('FAIL5', 'not-modified'): "recover_silent",
		('FAIL5', 'error'): "markDeleted",
		('FAIL5', 'forbidden'): "markDeleted",
	}
	for i in range(1, 5):
		transitions['FAIL%d'%i, 'success'] = 'recover'
		transitions['FAIL%d'%i, 'error'] = 'enterFAIL%d'%(i+1)
		transitions['FAIL%d'%i, 'forbidden'] = 'markDeleted'
		transitions['FAIL%d'%i, 'not-modified'] = 'recover_silent'

	@staticmethod
	def nop(row, payload):
		pass

	@staticmethod
	def update(row, payload):
		row["rectimestamp"] = datetime.datetime.utcnow()
		row["status"] = "OK"

	@staticmethod
	def recover(row, payload):
		row["rectimestamp"] = datetime.datetime.utcnow()
		row["status"] = "OK"

	@staticmethod
	def recover_silent(row, payload):
		# this is for when there were connections problems or the like
		# and our previous record is still valid when the service
		# is reachable again.
		row["status"] = "OK"

	@staticmethod
	def enterFAIL1(row, payload):
		row["status"] = "FAIL1"

	@staticmethod
	def enterFAIL2(row, payload):
		row["status"] = "FAIL2"
		fillers = {
			'admin_addr': api.getConfig("maintainerAddress"),
			'errmsg': utils.safe_str(payload)
		}
		fillers.update(row)
		osinter.sendMail(_FAIL1_MAIL_TEMPLATE.format(**fillers))

	@staticmethod
	def enterFAIL3(row, payload):
		row["status"] = "FAIL3"

	@staticmethod
	def enterFAIL4(row, payload):
		row["status"] = "FAIL4"

	@staticmethod
	def enterFAIL5(row, payload):
		row["status"] = "FAIL5"
		fillers = {
			'admin_addr': api.getConfig("maintainerAddress"),
			'errmsg': utils.safe_str(payload)
		}
		fillers.update(row)
		osinter.sendMail(_FAIL2_MAIL_TEMPLATE.format(**fillers))

	@staticmethod
	def markDeleted(row, payload):
		row["status"] = "DELETED"
		row["xml_source"] = None # deleted records have no metadata.
		row["rectimestamp"] = datetime.datetime.utcnow()
		row["modification_date"] = None

	@classmethod
	def process(cls, row, input, payload=None):
		try:
			getattr(cls, cls.transitions[row["status"], input])(row, payload)
		except Exception as exc:
			api.ui.notifyError("purx state machine failed: %s"%exc)

		updateRow(row)

	
def updateRow(row):
	"""updates row in the database in a single transition.

	Neither ivoid nor source_url can be changed in this way.
	"""
	with api.getWritableAdminConn() as conn:
		conn.execute("UPDATE purx.sources SET"
			" rectimestamp=%(rectimestamp)s,"
			" upstream_update=%(upstream_update)s,"
			" modification_date=%(modification_date)s,"
			" status=%(status)s,"
			" xml_source=%(xml_source)s"
			" WHERE ivoid=%(ivoid)s", row)


def getRow(sourceURL):
	"""returns the purx.sources row for sourceURL.

	If it doesn't exist, you'll get a ValidationError for url.
	"""
	with api.getTableConn() as conn:
		res = list(conn.queryToDicts("SELECT * FROM purx.sources"
			" WHERE source_url=%(sourceURL)s", locals()))
	if not res:
		raise api.ValidationError("No RR from URL %s registered here"%sourceURL,
			"url")
	return res[0]


def _handleUpdateErrors(ex, row):
	"""performs actions for handling an exception ex while updating row.

	In general, HTTP errors lead to transitions in a state machine;
	validation errors we consider to be under the immediate control of
	the contact person, and we complain immediately for now.
	"""
	if (isinstance(ex, requests.exceptions.HTTPError) and
		ex.response.status_code==403):
			StateMachine.process(row, "forbidden")
	else:
		# for now, we consider all other errors as equivalent and,
		# every 300 ks, move the state machine ahead
		if (datetime.datetime.utcnow()-row["rectimestamp"]).total_seconds()>3e5:
			StateMachine.process(row, "error", payload=ex)
	

def updateSource(row):
	"""updates the record in row if necessary and returns the updated
	record if so.

	This will raise an NotModified exception if no changes
	need to be dealt with.
	"""
	try:
		tree, lastModified = getRegistryRecord(
			row["source_url"],
			lastModified=row["modification_date"])
		ensureValidVOResource(tree)

	except NotModified as ex:
		return

	except Exception as ex:
		_handleUpdateErrors(ex, row)

	else:
		newUpstreamUpdate = api.parseISODT(tree.get("updated"))
		if newUpstreamUpdate<=row["upstream_update"]:
			row["modification_date"] = lastModified
			StateMachine.process(row, "success")
			raise NotModified(
				"@updated on record says it's the same thing")
		
		row["upstream_update"] = newUpstreamUpdate
		row["modification_date"] = lastModified
		row["xml_source"] = serializeTree(
			fixIdentifier(tree, row["ivoid"])).decode("utf-8")
		row["title"] = tree.xpath("title")[0].text
		try:
			row["contact_email"] = getContactEmail(tree)
		except api.ValidationError:
			# someone has botched the contact address.  Let's keep what we have.
			pass

		StateMachine.process(row, "success")

		return row


def expireStalePENDING(expiryPeriod):
	"""removes all PENDING records older than expiryPeriod.
	"""
	ageLimit = datetime.datetime.utcnow(
		)-datetime.timedelta(seconds=expiryPeriod)

	with api.getWritableAdminConn() as conn:
		conn.execute("DELETE FROM purx.sources WHERE"
			" status='PENDING'"
			" and rectimestamp<%(ageLimit)s", locals())


def updateAll():
	"""tries to update all records in purx.sources.

	This is what's being run every 20 hours from a cron job in purx/q; there's
	no harm in running it out of order now and then; nothing should be
	conditioned on the frequency at which this is run.

	This will also remove requests that have been pending for more than 150 ks.
	"""
	with api.getTableConn() as conn:
		for row in conn.queryToDicts("SELECT * FROM purx.sources"
				" WHERE status!='DELETED'"):
			try:
				updateSource(row)
			except NotModified:
				pass
			except Exception as msg:
				api.ui.notifyError("Unhandled exception while updating purx"
					" resource %s: %s"%(row["ivoid"], msg))

	expireStalePENDING(1.5e5)


def dumpIfChanges():
	"""dumps the database table to dumps/purx.ddump if its timestamp is
	older than the newest rectimestamp.

	The data is written as a DaCHS dump file (for use with dump load).

	This is called by a daily cron job in q.rd.
	"""
	rd = api.getRD(RD_ID)
	destFile = rd.getAbsPath("data/purx_source.dump")
	if os.path.exists(destFile):
		lastDump = datetime.datetime.utcfromtimestamp(
			os.path.getmtime(destFile))
	else:
		lastDump = datetime.datetime(1970, 1, 1)

	with api.getTableConn() as conn:
		lastTableChange = list(conn.query(
			"SELECT min(rectimestamp) FROM purx.sources"))[0][0]
	
	if lastDump<lastTableChange:
		with utils.safeReplaced(destFile) as f:
			api.createDump(["purx/q#sources"], f)
			


################# Web interface


from twisted.web.template import tags as T
from gavo import formal

class ConfirmationPage(
		formal.ResourceWithForm,
		web.CustomTemplateMixin,
		web.ServiceBasedPage):
	name = "custom"
	customTemplate = "res/confirmation.html"

	actionInfo = ""

	def __init__(self, segments, request, service):
		web.ServiceBasedPage.__init__(self, request, service)
		try:
			self.confirmationCode = segments[0]
		except IndexError:
			self.confirmationCode = "invalid"
		self.rec = {}

	def data_rec(self, request, tag):
		"""puts the row changed with this confirmation into the context.

		This will be empty unless something has actually
		been confirmed.
		"""
		return self.rec

	@template.renderer
	def inspectlink(self, request, tag):
		"""furnishes the host element with an href to this record's
		qp page.
		"""
		return tag(href=self.service.rd.getById("urlstatus"
			).getURL("qp")+"/"+urllib.parse.quote(self.rec["source_url"]))

	def render(self, request):
		self.rec = runConfirmationFor(self.confirmationCode)
		return formal.ResourceWithForm.render(self, request)


class WaitingPage(
		web.CustomTemplateMixin,
		web.ServiceBasedPage):
	name = "custom"
	customTemplate = "res/wait_for_mail.html"

	def __init__(self, destMail, request, service):
		web.ServiceBasedPage.__init__(self, request, service)
		self.destMail = destMail
	
	def data_destMail(self, request, tag):
		return self.destMail


class MainPage(
		formal.ResourceWithForm,
		web.CustomTemplateMixin,
		web.ServiceBasedPage):

	name = "custom"
	customTemplate = "res/enrollment.html"

	excHint_data = None

	@classmethod
	def isBrowseable(self, service):
		return True

	def form_genForm(self, request, data={}):
		self.regform = formal.Form()
		self.regform.addField("url", formal.String(required=True),
			label="VOResource URL",
			description="The URL at which to retrieve the VOResource."),
		self.regform.addAction(self.submitAction, label="Next")
		return self.regform

	@template.renderer
	def excHint(self, request, tag):
		if self.excHint_data:
			return [T.h2["Hints on fixing the problem"],
				tag[self.excHint_data],
				T.p["If this does not help, please consult the  ",
					T.a(href="mailto:"+api.getConfig("maintainerAddress"))[
					"site operators"],"."]]
		return ""

	def submitAction(self, request, form, data):
		return threads.deferToThread(
			self.runEnrollmentFor, data["url"]
			).addCallback(self._renderResponse, request
			).addErrback(self._handleInputErrors)

	def runEnrollmentFor(self, url):
		"""runs the registration for ivoid up to sending the confirmation mail.

		This returns the address the e-mail has been sent to.
		"""
		row = runEnrollmentFor(url)
		mailToSend = prepareMailing(row)
		osinter.sendMail(mailToSend)
		return row["contact_email"]

	def _renderResponse(self, result, request):
		return WaitingPage(result, request, self.service)

	def _handleInputErrors(self, failure):
		"""goes as an errback to form handling code to allow correction form
		rendering at later stages than validation.
		"""
		if hasattr(failure.value, "hint"):
			self.excHint_data = failure.value.hint

		if isinstance(failure.value, api.ValidationError
				) and isinstance(failure.value.colName, str):
			try:
				# Find out the formal name of the failing field...
				failedField = failure.value.colName
				# ...and make sure it exists
				self.regform.items.getItemByName(failedField)
				self.regform.errors.add(formal.FieldValidationError(
					str(failure.getErrorMessage()), failedField))

			except KeyError: # Failing field cannot be determined
				self.regform.errors.add(formal.FormError("Problem with input"
					" in the internal or generated field '%s': %s"%(
						failure.value.colName, failure.getErrorMessage())))
		else:
			api.ui.notifyFailure(failure)
			return failure
		return self.regform.errors

	def getChild(self, name, request):
		if name==b"confirm":
			return ConfirmationPage(
				request.popSegments(None), request, self.service)
		elif name==b"":
			return self
		raise api.UnknownURI("purx doesn't support that action.")



if __name__=="__main__":
	from gavo.user import logui
	logui.LoggingUI(api.ui)
	updateAll()
