# coding: utf-8
#
# Update LANCOM Wireless Displays with room reservations from Microsoft Exchange
# written by Martin Gordziel for LANCOM Systems GmbH in 2014-2019

#
# NOTE: this code is provided as a example how to update labels. It is targeted for a laboratory testbed.
# The code does not focus on securing the connection to an Exchange Server. This has to be implemented/verified by the user.
#

#
# prerequisites:
#
#  * install ironpython from http://ironpython.net/
#
#  * install "Microsoft Exchange Web Services Managed API" (use websearch) and include it in the PYTHONPATH environment variable.
#    If not set in IRONPYTHONPATH environment, uncomment and change the following path to where EWS got installed
# PATH_TO_EXCHANGE_WEB_SERVICES_MANAGED_API = r'C:\Program Files\Microsoft\Exchange\Web Services\2.2'
#



#
# NOTE: when connecting to Exchange 2019 fails, the following code snippets can be uncommented experimentally.
# This part allows TLS1.2 for installations where it is not the .NET default
#
# import System.Net.ServicePointManager
# import System.Net.SecurityProtocolType
# System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12
#
#
# This part disables certificate checking. UNCOMMENT AT YOUR OWN RISK.
#
# from System.Net.Security import RemoteCertificateValidationCallback
# def validate_certificate(sender, certificate, chain, sslPolicyErrors):
# 	return True
#
# System.Net.ServicePointManager.ServerCertificateValidationCallback = validate_certificate
#


#
# Code starts here
#

# Python imports
import os
import sys
import signal
import time
import datetime
import traceback
import argparse
import json
import xml.etree.ElementTree as ET
import xml.sax.saxutils as saxutils
import httplib
import hashlib
import logging
logger = logging.getLogger(__name__)

# .NET imports
try:
	#if PATH_TO_EXCHANGE_WEB_SERVICES_MANAGED_API is set, append it to path
	sys.path.append(PATH_TO_EXCHANGE_WEB_SERVICES_MANAGED_API)
except:
	pass
import clr
clr.AddReferenceToFile('Microsoft.Exchange.WebServices.dll')
import Microsoft.Exchange.WebServices.Data as EWSData
import System.DateTime


def print_console(text=''):
	print text


class WirelessDisplayServer(object):
	"""
	implements HTTP methods for the LANCOM Wireless ePaper Display server
	.post(id, xml) sends XML to server
	.push_Label(label) takes a Label class instance and sends it to server
	.get_transaction_updatestatus(id) returns a dictionary with the updatestatus of a display
	"""

	def __init__(self, address=None, port=None):
		self.server_address = ''
		self.server_port = '8001'
		if address is not None:
			self.server_address = address
		if port is not None:
			self.server_port = port

	def post(self, label_id, xml):
		"""
		post XML to server

		returns:
			False if it failed
			Transaction_ID if POST was successful and returned the ID
		"""
		rc = False
		h = httplib.HTTPConnection(self.server_address, self.server_port, timeout = 10)
		headers = {'Content-Type': 'application/xml'}
		try:
			h.request('POST', '/service/task', xml, headers)
			response = h.getresponse()
			if response.status == 200:
				xml_response = response.read()
				root = ET.fromstring(xml_response)
				if root.tag=='Transaction':
					rc = root.attrib['id']
					logger.debug('pushed via {0}:{1} to {2} under Transaction ID {3}'.format(self.server_address, self.server_port, label_id, rc))
				else:
					logger.warning('pushed via {0}:{1} to {2}, but got no Transaction ID'.format(self.server_address, self.server_port, label_id))
			else:
				logger.warning('got response status {} to POST'.format(response.status))
		except:
			logger.warning('could not communicate with server:')
			logger.warning(traceback.format_exc())
		h.close()
		return rc

	def push_Label(self, label):
		return self.post(label.label_id, label.get_xml())

	def get_transaction_updatestatus(self, transaction_id):
		"""
		get the status of a transaction (e.g 48)
		"""
		info = dict()
		h = httplib.HTTPConnection(self.server_address, self.server_port, timeout = 10)
		try:
			h.request('GET', '/service/updatestatus/transaction/{0}'.format(transaction_id))
			response = h.getresponse()
			if response.status == 200:
				xml_response = response.read()
				root = ET.fromstring(xml_response)
				if root.tag=='UpdateStatusPagedResult':
					for child in root:
						if child.tag=='UpdateStatus':
							info.update(child.attrib)
							info.update(dict((el.tag, el.text) for el in child))
							logger.info('got Transaction {0} UpdateStatus {1}'.format(transaction_id, ', '.join(['{0}={1}'.format(key, value) for key, value in info.items()])))
							break
				else:
					logger.warning('extpected result of type UpdateStatusPagedResult but got {0}'.format(root.tag))

			else:
				logger.warning('got HTTP response {0}'.format(response.status))
		except:
			logger.warning('could not communicate with server:')
			logger.warning(traceback.format_exc())
		h.close()
		return info


class ConferenceLabel(object):
	"""
	LANCOM Label for Conference Rooms
	label_id and template get filled from the config
	room_name and the fields dictionary get filled from the calendar

	.get_xml() returns the XML which can be POSTed to server
	.get_hash() returns a hash of the XML, e.g. to check if the XML has changed
	has its own .__str__() to allow a pretty "print my_label_instance"
	"""

	def __init__(self):
		self.label_id = ''
		self.template = ''
		self.fields = {'date': '', 'time1': '', 'purpose1': '', 'chair1': '', 'time2': '', 'purpose2': '', 'chair2': ''}
		self.room_name = ''

	def get_xml(self):
		roomName = saxutils.quoteattr(self.room_name)
		fields = ''.join(['			<field key="{0}" value={1}/>\n'.format(key, saxutils.quoteattr(value)) for key, value in self.fields.iteritems()])
		xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<TaskOrder title="templated data for label {0}">
	<TemplateTask labelId="{0}" template="{1}">
		<room roomName={2}>
{3}		</room>
	</TemplateTask>
</TaskOrder>'''.format(self.label_id, self.template, roomName, fields).encode('utf-8')
		logger.debug('generated XML:\n'+xml)
		return xml

	def get_hash(self):
		h = hashlib.sha1(self.get_xml()).hexdigest()
		logger.debug('hash value is: "{}"'.format(h))
		return h

	def __str__(self):
		pretty = []
		pretty.append('{}: {} @ {}'.format(self.label_id, self.room_name, self.fields['date']))
		line = '          '
		if len(self.fields['time1'])>0: line += '{}: '.format(self.fields['time1'])
		line += self.fields['purpose1']
		if len(self.fields['chair1'])>0: line += ' ({})'.format(self.fields['chair1'] )
		pretty.append(line)
		line = '          '
		if len(self.fields['time2'])>0: line += '{}: '.format(self.fields['time2'])
		line += self.fields['purpose2']
		if len(self.fields['chair2'])>0: line += ' ({})'.format(self.fields['chair2'] )
		if len(line.strip())>0: pretty.append(line)
		return '\n'.join(pretty)


class Exchange(object):
	"""
	Connect to Exchange server via the managed API
	"""

	def __init__(self):
		# exchange version has to be known to communicate correctly with server, see .init_service() for choices
		self.exchange_version = ''

		# exchange endpoint definition
		self.exchange_autodiscover_url_from_email = True
		# if True, specify:
		self.exchange_autodiscover_email = ''
		# if False, specify:
		self.exchange_url = ''

		# exchange credentials
		self.exchange_default_credentials = True
		# if True, the credentials of the current user are used
		# if False, specify:
		self.exchange_user = ''
		self.exchange_password = ''
		self.exchange_domain = ''
		self.exchange_sensitivity = 'all'
		self.not_public_subject = ''

	def init_service(self):
		logger.debug('starting init_service')

		# exchange version
		exchange_versions = {
			'Exchange2007_SP1': EWSData.ExchangeVersion.Exchange2007_SP1,
			'Exchange2010': EWSData.ExchangeVersion.Exchange2010,
			'Exchange2010_SP1': EWSData.ExchangeVersion.Exchange2010_SP1,
			'Exchange2010_SP2': EWSData.ExchangeVersion.Exchange2010_SP2,
			'Exchange2013': EWSData.ExchangeVersion.Exchange2013,
			'Exchange2013_SP1': EWSData.ExchangeVersion.Exchange2013_SP1,
		}
		# try to add experimental (?) versions found on github code
		try:
			exchange_versions['Exchange2015'] = EWSData.ExchangeVersion.Exchange2015
		except:
			pass
		try:
			exchange_versions['Exchange2016'] = EWSData.ExchangeVersion.Exchange2016
		except:
			pass
		try:
			exchange_versions['V2015_10_05'] = EWSData.ExchangeVersion.V2015_10_05
		except:
			pass

		if self.exchange_version in exchange_versions:
			self.service = EWSData.ExchangeService(exchange_versions[self.exchange_version])
		else:
			logger.critical('Exchange Version not known')
			sys.exit(1)

		logger.debug('setting credentials')
		if self.exchange_default_credentials:
			# Connect by using the default credentials of the authenticated user
			self.service.UseDefaultCredentials = True
		else:
			# Connect by using the specified credentials
			self.service.UseDefaultCredentials = False
			self.service.Credentials = EWSData.WebCredentials(self.exchange_user, self.exchange_password, self.exchange_domain)

		logger.debug('setting url')
		if self.exchange_autodiscover_url_from_email:
			self.service.AutodiscoverUrl(self.exchange_autodiscover_email)
		else:
			self.service.Url = System.Uri(self.exchange_url)
		print_console('using Exchange at {}'.format(self.service.Url))
		logger.debug('finished init_service')

	def get_room_usage(self, room_email, start_date=None, end_date=None):
		"""
		query the room usage for the specified room (given as room_email)

		important: the room mailbox must be configured to NOT delete the subject and NOT to add the organizer to the subject

		the date range can be specified, otherwise the complete current day is used.
		note: meetings spanning more than one day are not supported.
		"""

		logger.debug('get room usage for room mailbox {}'.format(room_email))

		# open room mailbox and go to calendar
		try:
			mailbox = EWSData.Mailbox(room_email)
			folderId = EWSData.FolderId(EWSData.WellKnownFolderName.Calendar, mailbox)
			calendar = EWSData.CalendarFolder.Bind(self.service, folderId)
		except:
			logger.warning('could not access room mailbox {}: {}'.format(room_email, traceback.format_exc()))
			return None

		# build CalendarView query for date range and limit results to the wanted members
		if start_date is None:
			start_date = System.DateTime.Now.Date
		if end_date is None:
			end_date = System.DateTime.Now.AddDays(1).Date
		logger.debug('date range from {} to {}'.format(start_date.ToString(), end_date.ToString()))
		calendar_view = EWSData.CalendarView(start_date, end_date, 100)
		calendar_view.PropertySet = EWSData.PropertySet(
				EWSData.AppointmentSchema.Start,
				EWSData.AppointmentSchema.End,
				EWSData.AppointmentSchema.Subject,
				EWSData.AppointmentSchema.Organizer,
				EWSData.AppointmentSchema.Sensitivity)

		# perform query and fill room_usage dictionary
		appointments = calendar.FindAppointments(calendar_view)
		room_usage = []
		for app in appointments:
			try:
				meeting = {'start_DateTime': app.Start, 'end_DateTime': app.End, 'subject': '', 'organizer' : ''}
				try: 
					logger.debug('Sensitivity in appointment {}, configured to show {}'.format(app.Sensitivity, self.exchange_sensitivity))
					if self.exchange_sensitivity=='all' or app.Sensitivity.ToString()=='Normal':
						meeting['subject'] = app.Subject.ToString()
					else:
						meeting['subject'] = self.not_public_subject
				except:
					logger.debug('malformed subject: '+traceback.format_exc())
					pass
				try:
					meeting['organizer'] = app.Organizer.Name.ToString()
				except:
					logger.debug('malformed orginizer name: '+traceback.format_exc())
					pass
				room_usage.append(meeting)
				logger.debug('-> '+', '.join('{}: {}'.format(k,v) for k,v in meeting.iteritems()))
			except:
				logger.warning('skipped malformed appointment: '+traceback.format_exc())

		logger.info('got {} meetings for room {} in range {} - {}'.format(len(room_usage), room_email, start_date, end_date))
		return room_usage

	def get_next_meetings(self, room_email_list):
		"""
		loop through all rooms (all queries use the same time range)
		"""
		now = System.DateTime.Now
		start_date = now.Date
		end_date = now.AddDays(1).Date

		next_meetings = {}

		for room_email in room_email_list:
			room_usage = self.get_room_usage(room_email, start_date, end_date)
			if room_usage is not None:
				next_usage = []
				for usage in room_usage:
					if usage['end_DateTime']>now:
						next_usage.append(usage)
				next_meetings[room_email] = {'date': start_date.ToString('d'), 'details': next_usage}
		return next_meetings


class Updater(object):
	"""
	Update the LANCOM Wireless ePaper Displays
	"""

	def __init__(self):
		self.config = None # dict set  by load_config
		self.args = None # instance set by argument parser
		self.label_hashes = {} # keep hashes of XML sent to server
		self.label_transactions = {} # unfinished transactions
		self.stats = {'skipped': 0, 'updates (new)': 0, 'updates finished successfully': 0, 'updates (repeated)': 0}

	def load_config(self, file):
		"""
		load a JSON config file

		return success as True/False
		"""
		if os.path.exists(file):
			with open(file, 'r') as f:
				try:
					self.config = json.load(f)
					return True
				except:
					logger.critical('Could not parse the config file: '+traceback.format_exc())
					self.config = None
		else:
			self.config = None
		return False

	def save_config(self, file):
		"""
		Save config as JSON
		"""
		with open(file, 'w') as f:
			f.write(json.dumps(self.config, sort_keys=True, indent=4, separators=(',',':')))

	def connect_exchange(self):
		"""
		configure a Exchange instance based on the parameters in the config file
		"""
		self.ews = Exchange()
		for element in ['version', 'default_credentials', 'user', 'password', 'domain', 'autodiscover_url_from_email', 'autodiscover_email', 'url']:
			if element in self.config['exchange_server']:
				setattr(self.ews, 'exchange_'+element, self.config['exchange_server'][element])
			else:
				setattr(self.ews, 'exchange_'+element, '')
		self.ews.exchange_sensitivity = self.args.sensitivity
		if 'not_public_message' in self.config['conference_label']:
			self.ews.not_public_subject = self.config['conference_label']['not_public_message'].decode('utf-8')
		self.ews.init_service()

	def connect_display_server(self):
		"""
		configure a WirelessDisplayServer instance based on the parameters in the config file
		"""
		self.wds = WirelessDisplayServer()
		for element in ['address', 'port']:
			if element in self.config['wireless_display_server']:
				setattr(self.wds, 'server_'+element, self.config['wireless_display_server'][element])
			else:
				setattr(self.wds, 'server_'+element, '')

	def update(self):
		"""
		update all displays (from config/displays)

		collects data from Exchange, and decides if a Update should be performed with the Wireless Display Server
		"""
		print_console('{}: updating labels...'.format(datetime.datetime.now().strftime(('%Y-%m-%d %H:%M:%S'))))

		next_meetings = self.ews.get_next_meetings(display['exchange_room_mailbox'] for display in self.config['displays'])
		for display_data in self.config['displays']:
			if display_data['exchange_room_mailbox'] in next_meetings:
				meeting_date = next_meetings[display_data['exchange_room_mailbox']]['date']
				meeting_details = next_meetings[display_data['exchange_room_mailbox']]['details']
				# fill Label instance
				label = ConferenceLabel()
				label.template = self.config['conference_label']['template']
				if 'template' in display_data:
					label.template = display_data['template']
				label.label_id = display_data['display_id']
				label.room_name = display_data['display_name'].decode('utf-8') # json data comes in utf-8
				label.fields['date'] = meeting_date
				logger.debug('processing label {} from {}: date {}, length of details {}, {}'.format(label.label_id, display_data['exchange_room_mailbox'], repr(meeting_date), len(meeting_details), meeting_details))
				if len(meeting_details)>0:
					logger.debug('added first meeting to label {} from {}'.format(label.label_id, display_data['exchange_room_mailbox']))
					label.fields['time1'] = '{}-{}'.format(meeting_details[0]['start_DateTime'].ToString('t'), meeting_details[0]['end_DateTime'].ToString('t'))
					label.fields['purpose1'] = meeting_details[0]['subject']
					label.fields['chair1'] = meeting_details[0]['organizer']
					if len(meeting_details)>1:
						logger.debug('added second meeting to label {} from {}'.format(label.label_id, display_data['exchange_room_mailbox']))
						label.fields['time2'] = '{}-{}'.format(meeting_details[1]['start_DateTime'].ToString('t'), meeting_details[1]['end_DateTime'].ToString('t'))
						label.fields['purpose2'] = meeting_details[1]['subject']
						label.fields['chair2'] = meeting_details[1]['organizer']
				else:
					logger.debug('no meetings for label {} from {}'.format(label.label_id, display_data['exchange_room_mailbox']))
					label.fields['purpose1'] = self.config['conference_label']['no_new_data_message'].decode('utf-8') # json data comes in utf-8

				# decide if update should be performed
				do_update = True
				if self.args.update=='always':
					self.stats['updates (new)'] += 1
				elif self.args.update in ['newdata', 'required']:
					# build hash of label data
					if label.label_id not in self.label_hashes:
						self.label_hashes[label.label_id] = ''
					hash = label.get_hash()
					logger.debug('compare against stored hash value: "{}"'.format(self.label_hashes[label.label_id]))
					# if hash has changed, do update for both 'newdata' and 'required'
					if self.label_hashes[label.label_id] != hash:
						do_update = True
						self.stats['updates (new)'] += 1
						logger.info('update label because hash value of data has changed')
					elif self.args.update=='required':
						# do update if last transaction was not successful. but if transaction is still pending, do nothing
						if display_data['display_id'] in self.label_transactions:
							update_status = self.wds.get_transaction_updatestatus(self.label_transactions[display_data['display_id']])
							if update_status['Status']=='SUCCESSFUL':
								do_update = False
								del self.label_transactions[display_data['display_id']]
								self.stats['updates finished successfully'] += 1
								print_console('{}: last update went in state SUCCESSFUL'.format(display_data['display_id']))
							elif update_status['Status']=='WAITING':
								do_update = False
								print_console('{}: last update is in state WAITING'.format(display_data['display_id']))
							else:
								do_update = True
								print_console('{}: repeating update, previous one is in state {}'.format(display_data['display_id'], update_status['Status']))
								self.stats['updates (repeated)'] += 1
						else:
							do_update = False
							logger.debug('update skipped, hash has not changed and no ongoing transaction')
					else:
						do_update = False
						logger.debug('update skipped, hash has not changed')

					self.label_hashes[label.label_id] = hash

				if do_update:
					print_console(label)
					transaction = self.wds.push_Label(label)
					if transaction is False:
						print_console('{}: update failed, communication with wireless display server failed'.format(display_data['display_id']))
						self.label_hashes[label.label_id] = '' # invalidate hash
					else:
						self.label_transactions[display_data['display_id']] = transaction

				else:
					self.stats['skipped'] += 1
					print_console('{}: skipped update (update not necessary)'.format(display_data['display_id']))
			else:
				print_console('{}: skipped update, could not get reservations from Exchange'.format(display_data['display_id']))

	def run(self, fullhour=False):
		"""
		Run the update loop

		If specified, wait for fullhour before starting the loops
		Ctrl-c breaks the update loop and prints statistics
		"""
		self.connect_exchange()
		self.connect_display_server()

		self.start = datetime.datetime.now()

		if self.args.fullhour:
			# wait for full hour before starting update loop
			prewait = 3600 - (self.start.minute*60 + self.start.second)
			print_console('waiting for full hour ({} seconds)'.format(prewait))
			time.sleep(prewait)
			self.start = datetime.datetime.now()

		try:
			# update loop
			loop=1
			while True:
				print_console()
				self.update()
				now = datetime.datetime.now()
				wait = self.start+datetime.timedelta(seconds = self.args.interval*60*loop)-now
				wait_until = now+wait
				print_console('finished, next update at {}'.format(wait_until.strftime('%Y-%m-%d %H:%M:%S')))
				wait_seconds = wait.total_seconds()
				if wait_seconds>0:
					time.sleep(wait_seconds)
				loop += 1
		except (KeyboardInterrupt):
			# print stats and finish
			print_console()
			for k in ['updates (new)', 'updates (repeated)', 'updates finished successfully', 'skipped']:
				v = self.stats[k.strip()]
				if v>0:
					print_console('{0:30s} : {1:8d}'.format(k, v))
		except:
			raise



if __name__=='__main__':
	# configure argument parser
	parser = argparse.ArgumentParser(description='Update LANCOM Wireless Displays with room reservations from Microsoft Exchange, v1.07')
	parser.add_argument('config', help='filename of the configuration in JSON format (use UTF-8 without BOM)')
	parser.add_argument('-i', '--interval', type=int, default=5, help='interval (in minutes) between updates')
	parser.add_argument('-f', '--fullhour', action='store_true', help='wait for fullhour before starting update loop')
	parser.add_argument('-u', '--update', choices=['always', 'newdata', 'required'], default='required', help='each interval: update always, update those with new data, or update those which require an update (new data/last transmission not successful)')
	parser.add_argument('-s', '--sensitivity', choices=['all', 'public'], default='all', help='show subject for all reservations, or only public ones (suppresses those flagged with personal, private, or confidential). Configure not_public_message to set what is displayed if subject is suppressed.')
	parser.add_argument('-d', '--debuglevel', choices=['debug', 'info', 'warning', 'error', 'critical'], default='warning', help='debuglevel for logging')
	parser.add_argument('-q', '--quiet', action='store_true', help='quiet mode, no console output')

	# configure Updater and start the loop
	u = Updater()
	u.args = parser.parse_args()
	if u.args.quiet:
		def print_console(text=''): pass
	logging.basicConfig(level = getattr(logging, u.args.debuglevel.upper()), format = '%(asctime)s - %(levelname)s = %(message)s')
	if u.load_config(u.args.config):
		u.run()
	else:
		print 'could not load config: {}'.format(u.args.config)

