diff --git a/README.md b/README.md index 311ece4..8a2fb97 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,17 @@ that explains its license. ---- + + +## Keycloak + +To integrate Keycloak as a OAuth provider in Odoo, you need to install the following OCA module: +
+[auth_oidc](https://github.com/OCA/server-auth/tree/18.0/auth_oidc) + + +## Multi Session Odoo + +If you wish to enable multiple connections for the same account with an OAuth provider in Odoo, you'll need to install this OCA module: +
+[auth_oauth_multi_token](https://github.com/OCA/server-auth/tree/18.0/auth_oauth_multi_token) \ No newline at end of file diff --git a/openems/__manifest__.py b/openems/__manifest__.py index 232ebf1..0849898 100644 --- a/openems/__manifest__.py +++ b/openems/__manifest__.py @@ -1,7 +1,7 @@ { "name": "OpenEMS", "summary": "Everything related to OpenEMS (Open Energy Management System)", - "version": "16.0.1.0.1", + "version": "18.0.1.0.1", "author": "OpenEMS Association e.V.", "maintainer": "OpenEMS Association e.V.", "contributors": [ @@ -11,7 +11,7 @@ "website": "https://openems.io", "license": "AGPL-3", "category": "Specific Industry Applications", - "depends": ["base", "web", "mail", "crm", "resource", "stock", "web_m2x_options", "partner_firstname"], + "depends": ["base", "web", "mail", "crm", "resource", "stock", "web_m2x_options", "partner_firstname","auth_oauth"], "data": [ "data/ir_config_parameter.xml", "data/res_partner_category.xml", diff --git a/openems/controllers/alerting.py b/openems/controllers/alerting.py index 7a848f2..b05bc85 100644 --- a/openems/controllers/alerting.py +++ b/openems/controllers/alerting.py @@ -12,51 +12,60 @@ class SumState(Enum): class Message: sentAt: datetime edgeId: str - userIds: list[int] + userLogins: list[str] - def __init__(self, sentAt: datetime, edgeId: str, userIds: list[int]) -> None: + def __init__(self, sentAt: datetime, edgeId: str, userLogins : list[str]) -> None: self.sentAt = sentAt self.edgeId = edgeId - self.userIds = userIds + self.userLogins = userLogins class SumStateMessage(Message): state: SumState - def __init__(self, sentAt: datetime, edgeId: str, userIds: list[int], state: SumState) -> None: - super().__init__(sentAt, edgeId, userIds) + def __init__(self, sentAt: datetime, edgeId: str, userLogins: list[str], state: SumState) -> None: + super().__init__(sentAt, edgeId, userLogins) self.state = state class Alerting(http.Controller): __logger = logging.getLogger("Alerting") + __datetime_format = "%Y-%m-%d %H:%M:%S" @http.route("/openems_backend/mail/alerting_sum_state", type="json", auth="user") - def sum_state_alerting(self, sentAt: str, params: list[dict]): + def sum_state_alerting(self, sentAt: str, params: list[dict]) -> dict: msgs = self.__get_sum_state_params(sentAt, params) update_func = lambda role, at: { role.write({"sum_state_last_notification": at})} if len(msgs) == 0: self.__logger.error("Scheduled SumState-Alerting-Mail without any recipients!!!") + return {"status": "error", "message": "No recipients for sum state alerting"} template = request.env.ref('openems.alerting_sum_state') + mails_sent = 0 for msg in msgs: - self.__send_mails(template, msg, update_func) + mails_sent += self.__send_mails(template, msg, update_func) - return {} + return {"status": "success", "mails_sent": mails_sent} @http.route("/openems_backend/mail/alerting_offline", type="json", auth="user") - def offline_alerting(self, sentAt: str, params: list[dict]): + def offline_alerting(self, sentAt: str, params: list[dict]) -> dict: msgs = self.__get_offline_params(sentAt, params) update_func = lambda role, at: { role.write({"offline_last_notification": at})} - template = request.env.ref("openems.alerting_offline") + if len(msgs) == 0: + self.__logger.error("Scheduled Offline-Alerting-Mail without any recipients!!!") + return {"status": "error", "message": "No recipients for offline alerting"} + + mails_sent = 0 + for msg in msgs: - self.__send_mails(template, msg, update_func) + template = self.__get_template(msg.edgeId) + mails_sent += self.__send_mails(template, msg, update_func) - return {} + return {"status": "success", "mails_sent": mails_sent} def __get_offline_params(self, sentAt, params) -> list[Message]: msgs = list() - sent = datetime.strptime(sentAt, "%Y-%m-%d %H:%M:%S") + sent = datetime.strptime(sentAt, self.__datetime_format) for param in params: edgeId = param["edgeId"] recipients = param["recipients"] @@ -65,22 +74,49 @@ def __get_offline_params(self, sentAt, params) -> list[Message]: def __get_sum_state_params(self, sentAt, params) -> list[SumStateMessage]: msgs = list() - sent = datetime.strptime(sentAt, "%Y-%m-%d %H:%M:%S") + sent = datetime.strptime(sentAt, self.__datetime_format) for param in params: edgeId = param["edgeId"] recipients = param["recipients"] state = param["state"] msgs.append(SumStateMessage(sent, edgeId, recipients, state)); return msgs + + def __get_template(self, device_id): + oem, producttype = self.__get_device_data_for(device_id) + match (oem.casefold(), producttype.casefold()): + case ('openems', _): + return request.env.ref("openems.alerting_offline") - def __send_mails(self, template, msg: Message, update_func): + def __get_device_data_for(self, device_id) -> tuple[str, str]: + if device_id: + found_devices = http.request.env["openems.device"].search_read( + [("name", "=", device_id)], ["producttype", "oem"] + ) + if len(found_devices) == 1: + device = found_devices[0] + oem = device.get('oem') or 'openems' + producttype = device.get('producttype') or 'other' + return oem, producttype + + self.__logger.warning(f"no device with id '{device_id}' found, using fallback [oem=openems, producttype=other]") + return 'openems', 'other' + + def __send_mails(self, template, msg: Message, update_func) -> int: roles = http.request.env['openems.alerting'].search( - [('user_id','in',msg.userIds),('device_id','=',msg.edgeId)] + [('user_login','in', msg.userLogins),('device_id','=', msg.edgeId)] ) + if not roles or len(roles) == 0: + self.__logger.error(f"No AlertingSettings found for edgeId[{msg.edgeId}] and userLogins[{msg.userLogins}]!!!") + return 0 + + mails_sent = 0 for role in roles: try: - template.send_mail(res_id=role.id, force_send=True) + template.send_mail(res_id=role.id) update_func(role, msg.sentAt) + mails_sent += 1 except Exception as err: - self.__logger.error("[" + str(err) + "] Unable to send template[" + str(template.name) +"] to edgeUser[user=" + str(role.id) + ", edge=" + str(msg.edgeId)+ "]") \ No newline at end of file + self.__logger.error(f"[{err}] Unable to send template[{template.name}] to edgeUser[user={role.id}, edge={msg.edgeId}]") + return mails_sent \ No newline at end of file diff --git a/openems/controllers/openems_backend.py b/openems/controllers/openems_backend.py index d0dcb80..f84b845 100644 --- a/openems/controllers/openems_backend.py +++ b/openems/controllers/openems_backend.py @@ -3,15 +3,13 @@ class OpenemsBackend(http.Controller): @http.route("/openems_backend/info", auth="user", type="json") - def index(self): + def index(self, external_uid): # Get user - user_id = http.request.env.context.get("uid") res_users = http.request.env["res.users"].sudo() user_rec = res_users.search_read( - [("id", "=", user_id)], - ["login", "name", "groups_id", "global_role", "openems_language"], + [("oauth_uid", "=", external_uid)], + ["login", "name", "groups_id", "global_role", "openems_language", "settings"], )[0] - res_users.browse([user_id]) # Get res group model res_groups_model = http.request.env["res.groups"].sudo() @@ -23,73 +21,23 @@ def index(self): manager_group_id = manager_group["id"] reader_group_id = reader_group["id"] + settings = user_rec["settings"] if user_rec.get("settings") else {} + # Get user attributes global_role = user_rec["global_role"] if manager_group_id in user_rec["groups_id"]: # Manager group global_role = "admin" - # return empty device (use pagination) list if user is manager or reader + has_multiple_edges = False if manager_group_id in user_rec["groups_id"] or reader_group_id in user_rec["groups_id"]: - return { - "user": { - "id": user_rec["id"], - "login": user_rec["login"], - "name": user_rec["name"], - "global_role": global_role, - "language": user_rec["openems_language"], - "has_multiple_edges": True - }, - "devices": [], - } - - # Get specific Device roles - device_user_role_model = http.request.env["openems.device_user_role"] - user_role_ids = device_user_role_model.search_read( - [("user_id", "=", user_id)], ["id", "role"] - ) - - # Get Devices - device_model = http.request.env["openems.device"] - devices = device_model.search_read( - [], ["id", "name", "user_role_ids", "comment", "producttype", - "lastmessage", "first_setup_protocol_date", "openems_sum_state_level"] - ) - - devs = [] - for device_rec in devices: - # Set user role per group - role = "guest" - if manager_group_id in user_rec["groups_id"]: - # Manager group - role = "admin" - elif reader_group_id in user_rec["groups_id"]: - # Reader group - role = "guest" - - # Set specific user role - for device_role_id in device_rec["user_role_ids"]: - for user_role_id in user_role_ids: - if device_role_id == user_role_id["id"]: - role = user_role_id["role"] - - # Prepare result - dev = { - "id": device_rec["id"], - "name": device_rec["name"], - "comment": device_rec["comment"], - "producttype": device_rec["producttype"], - "role": role, - "lastmessage": device_rec["lastmessage"], - "openems_sum_state_level": device_rec["openems_sum_state_level"] - } - - if device_rec["first_setup_protocol_date"]: - dev["first_setup_protocol_date"] = device_rec[ - "first_setup_protocol_date" - ] - - devs.append(dev) + has_multiple_edges = True + else: + device_user_role_model = http.request.env["openems.device_user_role"] + user_role_ids = device_user_role_model.search_read( + [("user_id", "=", user_rec["id"])], ["id"], limit=2 + ) + has_multiple_edges = len(user_role_ids) > 1 return { "user": { @@ -98,17 +46,17 @@ def index(self): "name": user_rec["name"], "global_role": global_role, "language": user_rec["openems_language"], - "has_multiple_edges": len(devs) > 1 + "has_multiple_edges": has_multiple_edges, + "settings": settings }, - "devices": devs, + "devices": [], } @http.route("/openems_backend/get_edge_with_role", auth="user", type="json") - def get_edge_with_role(self, edge_id: str): - user_id = http.request.env.context.get("uid") + def get_edge_with_role(self, external_uid, edge_id: str): res_users = http.request.env["res.users"].sudo() user_rec = res_users.search_read( - [("id", "=", user_id)], + [("oauth_uid", "=", external_uid)], ["login", "name", "groups_id"], )[0] @@ -124,9 +72,9 @@ def get_edge_with_role(self, edge_id: str): # get devices for which the user has permissions device_model = http.request.env["openems.device"] - devices = device_model.search_read( + devices = device_model.with_user(user_rec["id"]).search_read( [("name", "=", edge_id)], - ["id", "name", "comment", "producttype", "lastmessage", "first_setup_protocol_date", "openems_sum_state_level"]) + ["id", "name", "comment", "producttype", "lastmessage", "first_setup_protocol_date", "openems_sum_state_level", "settings"]) if len(devices) != 1: return {} @@ -136,7 +84,7 @@ def get_edge_with_role(self, edge_id: str): # Get specific Device roles device_user_role_model = http.request.env["openems.device_user_role"] device_user_roles = device_user_role_model.search_read( - [("user_id", "=", user_id), + [("user_id", "=", user_rec["id"]), ("device_id", "=", device["id"])], ["id", "role"] ) @@ -162,18 +110,20 @@ def get_edge_with_role(self, edge_id: str): "lastmessage": device["lastmessage"], "openems_sum_state_level": device["openems_sum_state_level"] } + if device.get("settings"): + dev["settings"] = device["settings"] + if device["first_setup_protocol_date"]: dev["first_setup_protocol_date"] = device["first_setup_protocol_date"] return dev @http.route("/openems_backend/get_edges", auth="user", type="json") - def get_edges(self, limit, page, query=None, searchParams=None): + def get_edges(self, external_uid, limit, page, query=None, searchParams=None): # Get user - user_id = http.request.env.context.get("uid") res_users = http.request.env["res.users"].sudo() user_rec = res_users.search_read( - [("id", "=", user_id)], + [("oauth_uid", "=", external_uid)], ["login", "name", "groups_id", "global_role"], )[0] @@ -190,12 +140,13 @@ def get_edges(self, limit, page, query=None, searchParams=None): # Get specific Device roles device_user_role_model = http.request.env["openems.device_user_role"] user_role_ids = device_user_role_model.search_read( - [("user_id", "=", user_id)], ["id", "role"] + [("user_id", "=", user_rec["id"])], ["id", "role"] ) domains = [] logical_operators = [] additional_domains = [] + order = "" if query: logical_operators.extend(['|', '|']) domains = [ @@ -213,6 +164,24 @@ def get_edges(self, limit, page, query=None, searchParams=None): additional_domains.append( ("openems_sum_state_level", "in", sum_states)) + if searchParams.get("orderState"): + + def map_field(input): + lookup = {"id": "name_number", "comment": "comment", "sumState": "openems_sum_state_level"} + field = lookup.get(input) + + if not field: + raise ValueError("{input} is not supported") + return field + + order_state = list(map(lambda s: (map_field(s["field"]), s["sortOrder"]), searchParams.get("orderState"))) + + for index, item in enumerate(order_state): + order += item[0] + " " + item[1] + + if index < (len(order_state) - 1): + order += "," + if "isOnline" in searchParams: additional_domains.append( ("openems_is_connected", "=", searchParams.get("isOnline"))) @@ -230,10 +199,11 @@ def get_edges(self, limit, page, query=None, searchParams=None): # Get Devices device_model = http.request.env["openems.device"] - devices = device_model.search_read( + devices = device_model.with_user(user_rec["id"]).search_read( logical_operators, ["id", "name", "user_role_ids", "comment", "producttype", - "lastmessage", "first_setup_protocol_date", "openems_sum_state_level"], + "lastmessage", "first_setup_protocol_date", "openems_sum_state_level", "settings"], + order=order, limit=limit, offset=(page * limit) ) devs = [] @@ -263,6 +233,10 @@ def get_edges(self, limit, page, query=None, searchParams=None): "lastmessage": device_rec["lastmessage"], "openems_sum_state_level": device_rec["openems_sum_state_level"] } + + if device_rec.get("settings"): + dev["settings"] = device_rec["settings"] + if device_rec["first_setup_protocol_date"]: dev["first_setup_protocol_date"] = device_rec[ diff --git a/openems/controllers/setup_protocol.py b/openems/controllers/setup_protocol.py index d64b18e..4f73b3c 100644 --- a/openems/controllers/setup_protocol.py +++ b/openems/controllers/setup_protocol.py @@ -42,8 +42,8 @@ def index(self, setupProtocolId, edgeId): templates = self.getTemplates(device_rec[0]['oem'], ibnPdf) - templates['installer'].send_mail(setupProtocolId, force_send=True) - templates['customer'].send_mail(setupProtocolId, force_send=True) + templates['installer'].send_mail(setupProtocolId) + templates['customer'].send_mail(setupProtocolId) return {} @@ -65,11 +65,17 @@ def getTemplates(self, oem: str, protocol): return templates @http.route('/openems_backend/get_latest_setup_protocol', type='json', auth='user') - def get_latest_setup_protocol(self, edge_name): + def get_latest_setup_protocol(self, external_uid, edge_name): + res_users = http.request.env["res.users"].sudo() + user_rec = res_users.search_read( + [("oauth_uid", "=", external_uid)], + ["login"], + )[0] + # search for device device_model = request.env['openems.device'] - device = device_model.search([('name', '=', edge_name)]) - + device = device_model.with_user(user_rec[0]).search([('name', '=', edge_name)]) + response = dict() if not len(device.setup_protocol_ids) > 0: return response diff --git a/openems/controllers/user.py b/openems/controllers/user.py index 75ec137..27df7c4 100644 --- a/openems/controllers/user.py +++ b/openems/controllers/user.py @@ -24,7 +24,7 @@ def index(self, userId, password=None, oem: str = ''): } # send mail template.with_context(email_values).send_mail( - res_id=partner_id[0], force_send=True) + res_id=partner_id[0]) return {} def getTemplate(self, oem: str): diff --git a/openems/mail/openems/alerting_sum_state.xml b/openems/mail/openems/alerting_sum_state.xml index 5ac49f6..dee2ccc 100644 --- a/openems/mail/openems/alerting_sum_state.xml +++ b/openems/mail/openems/alerting_sum_state.xml @@ -6,6 +6,7 @@ ]]> {{object.user_id.partner_id.id}} + {{object.user_id.get_mapped_language()}} OpenEMS Alert - Edge is in {{object.device_id.openems_sum_state_level}} State diff --git a/openems/models/device.py b/openems/models/device.py index bdbda80..488a567 100644 --- a/openems/models/device.py +++ b/openems/models/device.py @@ -29,6 +29,7 @@ class Device(models.Model): "First Setup Protocol Date", compute="_compute_first_setup_protocol" ) manual_setup_date = fields.Datetime("Manual Setup Date") + settings = fields.Json() @api.depends("setup_protocol_ids", "manual_setup_date") def _compute_first_setup_protocol(self): @@ -112,7 +113,7 @@ def _compute_monitoring_url(self): ) # Helper fields - name_number = fields.Integer(compute="_compute_name_number", store="True") + name_number = fields.Integer(compute="_compute_name_number", store=True, index=True) @api.depends("name") def _compute_name_number(self): @@ -252,7 +253,7 @@ class OpenemsConfigUpdate(models.Model): _description = "OpenEMS Edge Device Configuration Update" _order = "create_date desc" - device_id = fields.Many2one("openems.device", string="OpenEMS Edge") + device_id = fields.Many2one("openems.device", string="OpenEMS Edge",index=True) teaser = fields.Text("Update Details Teaser") details = fields.Html("Update Details") @@ -263,7 +264,7 @@ class Systemmessage(models.Model): _order = "create_date desc" timestamp = fields.Datetime("Creation date") - device_id = fields.Many2one("openems.device", string="OpenEMS Edge") + device_id = fields.Many2one("openems.device", string="OpenEMS Edge",index=True) text = fields.Text("Message") text_teaser = fields.Char(compute="_compute_text_teaser") @@ -294,19 +295,19 @@ class Alerting(models.Model): offline_last_notification = fields.Datetime(string="Last Offline notification sent") sum_state_last_notification = fields.Datetime(string="Last SumState notification sent") - device_name = fields.Text(compute="_compute_device_name", store="True") - user_login = fields.Text(compute="_compute_user_login", store="True") + device_name = fields.Text(compute="_compute_device_name", store=True) + user_login = fields.Text(compute="_compute_user_login", store=True) user_role = fields.Selection( [("admin", "Admin"), ("installer", "Installer"), ("owner", "Owner"), ("guest", "Guest"),], - compute="_compute_user_role", store="False") + compute="_compute_user_role", store=False) @api.depends("device_id") def _compute_device_name(self): for rec in self: rec.device_name = rec.device_id.name; - @api.depends("user_id") + @api.depends("user_id","user_id.login") def _compute_user_login(self): for rec in self: rec.user_login = rec.user_id.login; diff --git a/openems/models/setup_protocol.py b/openems/models/setup_protocol.py index 548576e..bdc7cae 100644 --- a/openems/models/setup_protocol.py +++ b/openems/models/setup_protocol.py @@ -6,9 +6,9 @@ class SetupProtocol(models.Model): _description = "OpenEMS Edge Setup Protocols (IBN)" _order = "create_date desc" - customer_id = fields.Many2one("res.partner", "Customer", required=True) + customer_id = fields.Many2one("res.partner", "Customer") different_location_id = fields.Many2one("res.partner", "Different Location") - installer_id = fields.Many2one("res.partner", "Installer", required=True) + installer_id = fields.Many2one("res.partner", "Installer") device_id = fields.Many2one("openems.device", "OpenEMS Edge", required=True) productionlot_ids = fields.One2many( "openems.setup_protocol_production_lot", "setup_protocol_id", "Serial Numbers" @@ -16,6 +16,15 @@ class SetupProtocol(models.Model): item_ids = fields.One2many( "openems.setup_protocol_item", "setup_protocol_id", "Entry Items" ) + type = fields.Selection( + [ + ("setup-protocol", "Setup protocol"), + ("ems-exchange", "EMS exchange"), + ("capacity-extension", "Capacity extension"), + ], + "Type", + default="setup-protocol", + ) class SetupProtocolProductionLot(models.Model): diff --git a/openems/models/user.py b/openems/models/user.py index 321dd87..aef2673 100644 --- a/openems/models/user.py +++ b/openems/models/user.py @@ -35,3 +35,12 @@ class ResUsers(models.Model): default="DE", required=True, ) + oauth_uid = fields.Char(index=True) + settings = fields.Text("Custom Settings", readonly=True) + + def get_mapped_language(self): + lang = self.env["res.lang"] + if self.openems_language == "EN": + return lang.search(["code", "=", "en_US"], limit=1) + else: + return lang.search(["code", "=", "de_DE"], limit=1) \ No newline at end of file diff --git a/openems/report/setup_protocol.xml b/openems/report/setup_protocol.xml index c9db4ec..eb188a3 100644 --- a/openems/report/setup_protocol.xml +++ b/openems/report/setup_protocol.xml @@ -26,7 +26,9 @@ th { -

Setup Protocol

+

+ -Protokoll - OpenEMS +

@@ -39,60 +41,62 @@ th { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -165,60 +169,62 @@ th { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openems/views/device.xml b/openems/views/device.xml index e74b1cb..ffb8adf 100644 --- a/openems/views/device.xml +++ b/openems/views/device.xml @@ -5,7 +5,7 @@ Device: Tree openems.device - - + @@ -50,6 +50,15 @@ string="OpenEMS Version" context="{'group_by': 'openems_version'}" /> + + + @@ -132,10 +141,10 @@ name="user_role_ids" string="Access Role in Online-Monitoring" > - + - + @@ -149,12 +158,13 @@ name="setup_protocol_ids" string="Installation Log" > - + - + + @@ -183,25 +193,27 @@ name="alerting_settings" string="Alerting Settings" > - + + + - + -
+ -
+ @@ -210,10 +222,10 @@ Device DeviceUserRole: Tree openems.device_user_role - + - + @@ -221,7 +233,7 @@ Devices ir.actions.act_window openems.device - tree,form + list,form @@ -235,11 +247,11 @@ Device Configuration Updates: Tree openems.openemsconfigupdate - + - + @@ -269,11 +281,11 @@ Device Systemmessage: Tree openems.systemmessage - + - + diff --git a/openems/views/partner.xml b/openems/views/partner.xml index 874530e..bb843b1 100644 --- a/openems/views/partner.xml +++ b/openems/views/partner.xml @@ -9,16 +9,16 @@ - + - + - + - + diff --git a/openems/views/setup_protocol.xml b/openems/views/setup_protocol.xml index bf2eee2..45c9d50 100644 --- a/openems/views/setup_protocol.xml +++ b/openems/views/setup_protocol.xml @@ -20,6 +20,7 @@ + @@ -32,12 +33,12 @@ SetupProtocolProductionLot: Tree openems.setup_protocol_production_lot - + - + @@ -45,14 +46,14 @@ SetupProtocolItem: Tree openems.setup_protocol_item - + - + @@ -60,12 +61,12 @@ SetupProtocol: Tree openems.setup_protocol - + - + diff --git a/openems/views/user.xml b/openems/views/user.xml index 445eb4f..7a57e4c 100644 --- a/openems/views/user.xml +++ b/openems/views/user.xml @@ -12,10 +12,10 @@ - + - +
Kontaktdaten Endkunde
Firmenname - -
Vor- Nachname - - -
Straße / Hausnummer - -
PLZ - -
Ort - -
Land - -
E-Mail-Adresse - -
Telefonnummer - -
Kontaktdaten Endkunde
Firmenname + +
Vor- Nachname + + +
Straße / Hausnummer + +
PLZ + +
Ort + +
Land + +
E-Mail-Adresse + +
Telefonnummer + +
Installateur
Firmenname - -
Name Installateur - - -
Straße / Hausnummer - -
PLZ - -
Ort - -
Land - -
E-Mail-Adresse - -
Telefonnummer - -
Installateur
Firmenname + +
Name Installateur + + +
Straße / Hausnummer + +
PLZ + +
Ort + +
Land + +
E-Mail-Adresse + +
Telefonnummer + +