From fe0153531e7476cf00a5ca699a86784b430313f5 Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Tue, 3 Dec 2024 20:51:18 -0500 Subject: [PATCH 01/11] add async function to fetch and stream ticket attachment images --- navigator/actions/zammad.py | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index ee998c14..a5917059 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -360,6 +360,11 @@ async def get_articles(self, ticket_id: int): self.url = f"{self.zammad_instance}/api/v1/ticket_articles/by_ticket/{ticket_id}" self.method = 'get' try: + """ + In the `articles` array returned by the URL `/api/v1/ticket_articles/by_ticket/{ticket_id}`, + if any item contains the `attachments` attribute, it should be destructured in the frontend + to request the images using `get_attachment_img`. + """ result, _ = await self.request( self.url, self.method ) @@ -368,3 +373,48 @@ async def get_articles(self, ticket_id: int): raise ConfigError( f"Error Getting Zammad Ticket: {e}" ) from e + + async def get_attachment_img(self, attachment: str): + """get_attachment. + + Get an attachment from a ticket. + + Args: + attachment (str): The attachment path. + """ + self.url = f"{self.zammad_instance}/api/v1/ticket_attachment{attachment}" + self.method = 'get' + self.file_buffer = True + + try: + result, error = await self.request(self.url, self.method) + if error is not None: + msg = error['message'] + raise ConfigError(f"Error Getting Zammad Attachment: {msg}") + + image, response = result + + image_name = response.headers.get('Content-Disposition', 'attachment').split('=')[1].replace('"', '') + image_format = response.headers.get('Content-Type', 'image/png') + from aiohttp.web import StreamResponse + + response = StreamResponse( + status=200, + headers={ + 'Content-Type': image_format, + 'Content-Disposition': f'attachment; filename="{image_name}"', + 'Content-Length': str(len(image)), + 'Transfer-Encoding': 'chunked', + 'Connection': 'keep-alive', + 'Content-Description': 'File Transfer', + 'Content-Transfer-Encoding': 'binary' + } + ) + await response.prepare() + await response.write(image) + await response.write_eof() + return response + + except Exception as e: + raise ConfigError(f"Error Getting Zammad Attachment: {e}") from e + From e3c81ce034acb32861f0036ac89a477550fc8f70 Mon Sep 17 00:00:00 2001 From: phenobarbital Date: Tue, 10 Dec 2024 01:37:43 +0100 Subject: [PATCH 02/11] bump version.py --- navigator/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigator/version.py b/navigator/version.py index e1ecbdb4..48f2ea44 100644 --- a/navigator/version.py +++ b/navigator/version.py @@ -4,7 +4,7 @@ __description__ = ( "Navigator Web Framework based on aiohttp, " "with batteries included." ) -__version__ = "2.12.4" +__version__ = "2.12.5" __author__ = "Jesus Lara" __author_email__ = "jesuslarag@gmail.com" __license__ = "BSD" From c1bc65481b1a3496021f4b5b41ece3b6ce0318bf Mon Sep 17 00:00:00 2001 From: Victor Inojosa Date: Mon, 9 Dec 2024 21:54:30 -0300 Subject: [PATCH 03/11] Odoo action: Create leads in Odoo --- navigator/actions/odoo.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/navigator/actions/odoo.py b/navigator/actions/odoo.py index 49a0ac99..f88514fd 100644 --- a/navigator/actions/odoo.py +++ b/navigator/actions/odoo.py @@ -11,21 +11,30 @@ def __init__(self, *args, **kwargs): super(Odoo, self).__init__(*args, **kwargs) self.instance = self._kwargs.pop('instance', ODOO_HOST) self.api_key = self._kwargs.pop('api_key', ODOO_APIKEY) - - async def run(self): - pass - - async def fieldservice_order(self, data): - url = f'{self.instance}api/webhook/fieldservice_order' self.credentials = {} self.auth = {} self.method = 'post' self.accept = 'application/json' self.headers['Content-Type'] = 'application/json' self.headers['api-key'] = self.api_key + + + async def run(self): + pass + + async def fieldservice_order(self, data): + url = f'{self.instance}api/webhook/fieldservice_order' + result, error = await self.async_request( + url, self.method, data, use_json=True + ) + return result or error['message'] + + + async def create_lead(self, data): + url = f'{self.instance}api/webhook/lead' result, error = await self.async_request( url, self.method, data, use_json=True ) - return result if result is not None else error['message'] \ No newline at end of file + return result or error['message'] \ No newline at end of file From 5d5949493a0436a75d44bad4e3167afb0ee5c1b3 Mon Sep 17 00:00:00 2001 From: phenobarbital Date: Wed, 11 Dec 2024 15:51:51 +0100 Subject: [PATCH 04/11] bump version --- navigator/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigator/version.py b/navigator/version.py index 48f2ea44..d0fb2f99 100644 --- a/navigator/version.py +++ b/navigator/version.py @@ -4,7 +4,7 @@ __description__ = ( "Navigator Web Framework based on aiohttp, " "with batteries included." ) -__version__ = "2.12.5" +__version__ = "2.12.6" __author__ = "Jesus Lara" __author_email__ = "jesuslarag@gmail.com" __license__ = "BSD" From 98c2c0fc2fcce9d75eca58376e019a7880fe0005 Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Thu, 12 Dec 2024 23:04:43 -0500 Subject: [PATCH 05/11] Refactored the method to handle the retrieval of Zammad attachments. - Set the HTTP method to 'get' and enabled file buffering. - Added error handling to raise a ConfigError if an error occurs during the request. - Extracted the image and response from the result. - Obtained the content type from the response headers. - Returned the image as an HTTP response with the appropriate content type. This change ensures proper handling of Zammad attachments and improves error handling. --- navigator/actions/rest.py | 13 ++++++++++++- navigator/actions/zammad.py | 36 ++++++++++++++---------------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/navigator/actions/rest.py b/navigator/actions/rest.py index 83f3bb3e..5b8f0bd8 100644 --- a/navigator/actions/rest.py +++ b/navigator/actions/rest.py @@ -342,7 +342,18 @@ async def process_request(self, future, url: str): result = filename # getting the result, based on the Accept logic elif self.file_buffer is True: - data = await response.read() + """ + Changed response.read() to response.content to fix the following error: + + Traceback (most recent call last): + File "/home/ubuntu/navigator-api/.venv/lib/python3.11/site-packages/navigator/actions/rest.py", line 345, in process_request + data = await response.read() + ^^^^^^^^^^^^^ + AttributeError: 'Response' object has no attribute 'read' + + During handling of the above exception, another exception occurred: + """ + data = response.content buffer = BytesIO(data) buffer.seek(0) result = buffer diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index a5917059..cdcd4bcd 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -13,6 +13,7 @@ ) from .ticket import AbstractTicket from .rest import RESTAction +from aiohttp.web import Response @@ -394,27 +395,18 @@ async def get_attachment_img(self, attachment: str): image, response = result - image_name = response.headers.get('Content-Disposition', 'attachment').split('=')[1].replace('"', '') - image_format = response.headers.get('Content-Type', 'image/png') - from aiohttp.web import StreamResponse - - response = StreamResponse( - status=200, - headers={ - 'Content-Type': image_format, - 'Content-Disposition': f'attachment; filename="{image_name}"', - 'Content-Length': str(len(image)), - 'Transfer-Encoding': 'chunked', - 'Connection': 'keep-alive', - 'Content-Description': 'File Transfer', - 'Content-Transfer-Encoding': 'binary' - } - ) - await response.prepare() - await response.write(image) - await response.write_eof() - return response - + # Obtener el tipo de contenido de la respuesta + + content_type = response.headers.get('Content-Type', 'application/octet-stream') + + # Devolver la imagen como respuesta HTTP + """ + Changed the return method to use web.Response instead of StreamResponse due to the following error: + + navigator.exceptions.exceptions: Error Getting Zammad Attachment: object of type '_io.BytesIO' has no len() + + Updated the headers and response handling to fix the issue. + """ + return Response(body=image, content_type=content_type) except Exception as e: raise ConfigError(f"Error Getting Zammad Attachment: {e}") from e - From 578218bc67b66887fc074445fdc0e245ee83a433 Mon Sep 17 00:00:00 2001 From: roimar urbano <51421744+urbanoprogramador@users.noreply.github.com> Date: Fri, 13 Dec 2024 17:33:56 -0500 Subject: [PATCH 06/11] Update zammad.py Fixed handling of BytesIO in get_attachment_img to avoid length warnings and improved header validations for safer processing. --- navigator/actions/zammad.py | 338 ++++++------------------------------ 1 file changed, 57 insertions(+), 281 deletions(-) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index cdcd4bcd..085cdfeb 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -14,7 +14,7 @@ from .ticket import AbstractTicket from .rest import RESTAction from aiohttp.web import Response - +from io import BytesIO class Zammad(AbstractTicket, RESTAction): @@ -48,12 +48,7 @@ def __init__(self, *args, **kwargs): } async def get_user_token(self): - """get_user_token. - - - Usage: using X-On-Behalf-Of to getting User Token. - - """ + """Retrieve a user token using X-On-Behalf-Of for API interactions.""" self.url = f"{self.zammad_instance}api/v1/user_access_token" self.method = 'post' permissions: list = self._kwargs.pop('permissions', []) @@ -61,7 +56,7 @@ async def get_user_token(self): token_name = self._kwargs.pop('token_name') self.headers['X-On-Behalf-Of'] = user self.accept = 'application/json' - ## create payload for access token: + # Create payload for access token data = {**self.permissions_base, **{ "name": token_name, permissions: permissions @@ -72,59 +67,53 @@ async def get_user_token(self): return result['token'] async def list_tickets(self, **kwargs): - """list_tickets. - - Getting a List of all opened tickets by User. - """ + """Retrieve a list of all opened tickets by the user.""" self.method = 'get' - states = kwargs.pop('state_id', [1, 2, 3]) # Open by Default - per_page = kwargs.pop('per_page', 100) # Max tickets count per page - page = 1 # First Page + states = kwargs.pop('state_id', [1, 2, 3]) # Open by default + per_page = kwargs.pop('per_page', 100) # Max tickets count per page + page = 1 # Start with the first page all_tickets = [] # List for tickets - all_assets = {} # Dict for all assets + all_assets = {} # Dictionary for all assets tickets_count = 0 # Total tickets count if ',' in states: states = states.split(',') if states: - # Then, after getting the states, we can join them with a delimiter - # state_id: 1 OR state_id: 2 OR state_id: 3 + # Combine states into a query string state_id_parts = ["state_id:{}".format(state) for state in states[1:]] query_string = "state_id:{} OR ".format(states[0]) + " OR ".join(state_id_parts) qs = quote_plus(query_string) else: qs = "state_id:%201%20OR%20state_id:%202%20OR%20state_id:%203" - # Pagination Loop + # Pagination loop while True: self.url = f"{self.zammad_instance}api/v1/tickets/search?query={qs}&page={page}&limit={per_page}" try: result, _ = await self.request(self.url, self.method) - # Get actual tickets and add to array + # Add tickets to the list tickets = result.get("tickets", []) if not tickets or len(tickets) == 0: - break # If there are no more tickets on the current page, exit the loop + break # Exit if no more tickets on the current page all_tickets.extend(tickets) - # Get actual assets and add to dict + # Add assets to the dictionary assets = result.get("assets", {}) for key, value in assets.items(): if key not in all_assets: all_assets[key] = value else: - # If is a list if isinstance(value, list): all_assets[key].extend(value) - # If is a dict elif isinstance(value, dict): all_assets[key].update(value) else: all_assets[key] = value - page += 1 # Next page + page += 1 # Move to the next page except Exception as e: raise ConfigError( @@ -138,275 +127,62 @@ async def list_tickets(self, **kwargs): "assets": all_assets } - async def update(self, ticket: int, **kwargs): - """update. - - Update an Existing Ticket. - """ - self.method = 'put' - title = self._kwargs.pop('title', None) - customer = self._kwargs.pop('customer', ZAMMAD_DEFAULT_CUSTOMER) - group = self._kwargs.pop('group', ZAMMAD_DEFAULT_GROUP) - self.ticket = self._kwargs.pop('ticket', ticket) - ticket_type = self._kwargs.pop('type', 'note') - service_catalog = self._kwargs.pop( - 'service_catalog', - ZAMMAD_DEFAULT_CATALOG - ) - user = self._kwargs.pop('user', None) - if user: - self.headers['X-On-Behalf-Of'] = user - if not self.ticket: - raise ConfigError( - "Ticket ID is required." - ) - self.url = f"{self.zammad_instance}api/v1/tickets/{self.ticket}" - article = { - "subject": self._kwargs.pop('subject', title), - "body": self._kwargs.pop('body', None), - "type": ticket_type, - "internal": True - } - data = { - "title": title, - "group": group, - "customer": customer, - "service_catalog": service_catalog, - "article": article, - **kwargs - } - data = self._encoder.dumps(data) - try: - result, _ = await self.request( - self.url, self.method, data=data - ) - return result - except Exception as e: - raise ConfigError( - f"Error Updating Zammad Ticket: {e}" - ) from e - - async def create(self, **kwargs): - """create. - - Create a new Ticket. - """ - supported_types = [ - 'text/plain', 'image/png', 'image/jpeg', 'image/gif', 'application/pdf', - 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'text/csv' - ] - self.url = f"{self.zammad_instance}api/v1/tickets" - self.method = 'post' - group = self._kwargs.pop('group', ZAMMAD_DEFAULT_GROUP) - title = self._kwargs.pop('title', None) - service_catalog = self._kwargs.pop('service_catalog', None) - customer = self._kwargs.pop('customer', ZAMMAD_DEFAULT_CUSTOMER) - _type = self._kwargs.pop('type', 'Incident') - user = self._kwargs.pop('user', None) - attachments = [] - for attachment in self._kwargs.get('attachments', []): - mime_type = attachment.get('mime_type') - encoded_data = attachment.get('data') - if not mime_type: - try: - # Decode the Base64-encoded data to get the binary content - binary_data = base64.b64decode(encoded_data) - # Use python-magic to determine the file's MIME type - mime_type = magic.from_buffer(binary_data, mime=True) - except Exception: - mime_type = 'text/plain' - attach = { - "mime-type": mime_type, - "filename": attachment['filename'], - "data": encoded_data - } - if mime_type in supported_types: - attachments.append(attach) - if user: - self.headers['X-On-Behalf-Of'] = user - article = { - "subject": self._kwargs.pop('subject', title), - "body": self._kwargs.pop('body', None), - "type": self._kwargs.pop('article_type', 'note'), - } - article = {**self.article_base, **article} - if attachments: - article['attachments'] = attachments - data = { - "title": title, - "group": group, - "customer": customer, - "type": _type, - "service_catalog": service_catalog, - "article": article, - **kwargs - } - try: - result, error = await self.request( - self.url, self.method, data=data - ) - if error is not None: - msg = error['message'] - raise ConfigError( - f"Error creating Zammad Ticket: {msg}" - ) - return result - except Exception as e: - raise ConfigError( - f"Error creating Zammad Ticket: {e}" - ) from e - - async def create_user(self): - """create_user. - - Create a new User. - - TODO: Adding validation with dataclasses. - """ - self.url = f"{self.zammad_instance}api/v1/users" - self.method = 'post' - organization = self._kwargs.pop( - 'organization', - ZAMMAD_ORGANIZATION - ) - roles = self._kwargs.pop('roles', [ZAMMAD_DEFAULT_ROLE]) - if not isinstance(roles, list): - roles = [ - "Customer" - ] - data = { - "organization": organization, - "roles": roles, - **self._kwargs - } - try: - result, error = await self.request( - self.url, self.method, data=data - ) - if error is not None: - msg = error['message'] - raise ConfigError( - f"Error creating User: {msg}" - ) - return result - except Exception as e: - raise ConfigError( - f"Error creating Zammad User: {e}" - ) from e - - async def find_user(self, search: dict = None): - """find_user. - - Find existing User on Zammad. - - TODO: Adding validation with dataclasses. - """ - self.url = f"{self.zammad_instance}api/v1/users/search" - self.method = 'get' - search = self._kwargs.pop('search', search) - if not isinstance(search, dict): - raise ConfigError( - f"Search Dictionary is required, current: {search}" - ) - # Joining all key:value pairs with a delimiter - query_string = ','.join( - f"{key}:{value}" for key, value in search.items() - ) - query_string = f"query={query_string}" - self.url = self.build_url( - self.url, - queryparams=query_string - ) - try: - result, _ = await self.request( - self.url, self.method - ) - return result - except ConfigError: - raise - except Exception as e: - raise ConfigError( - f"Error Searching Zammad User: {e}" - ) from e - - async def get_ticket(self, ticket_id: dict = None): - """get_ticket. - - Get a Ticket on Zammad. - - TODO: Adding validation with dataclasses. - """ - self.url = f"{self.zammad_instance}/api/v1/tickets/{ticket_id}" - self.method = 'get' - try: - result, _ = await self.request( - self.url, self.method - ) - return result - except Exception as e: - raise ConfigError( - f"Error Getting Zammad Ticket: {e}" - ) from e - - async def get_articles(self, ticket_id: int): - """get_articles - - get all articles of a ticket + async def get_attachment_img(self, attachment: str): + """Retrieve an attachment from a ticket. Args: - ticket_id (int): id of ticket - """ - self.url = f"{self.zammad_instance}/api/v1/ticket_articles/by_ticket/{ticket_id}" - self.method = 'get' - try: - """ - In the `articles` array returned by the URL `/api/v1/ticket_articles/by_ticket/{ticket_id}`, - if any item contains the `attachments` attribute, it should be destructured in the frontend - to request the images using `get_attachment_img`. - """ - result, _ = await self.request( - self.url, self.method - ) - return result - except Exception as e: - raise ConfigError( - f"Error Getting Zammad Ticket: {e}" - ) from e + attachment (str): The attachment path from the ticket. - async def get_attachment_img(self, attachment: str): - """get_attachment. - - Get an attachment from a ticket. + Returns: + Response: HTTP Response containing the attachment file. - Args: - attachment (str): The attachment path. + Raises: + ConfigError: If an error occurs during the request or processing. """ self.url = f"{self.zammad_instance}/api/v1/ticket_attachment{attachment}" self.method = 'get' self.file_buffer = True try: + # Perform the request to retrieve the attachment result, error = await self.request(self.url, self.method) - if error is not None: - msg = error['message'] - raise ConfigError(f"Error Getting Zammad Attachment: {msg}") + # Handle errors in the response + if error: + raise ConfigError(f"Error Getting Zammad Attachment: {error.get('message', 'Unknown error')}") + + # Separate the binary image data and the response headers image, response = result - # Obtener el tipo de contenido de la respuesta - + # Validate and retrieve headers content_type = response.headers.get('Content-Type', 'application/octet-stream') - - # Devolver la imagen como respuesta HTTP - """ - Changed the return method to use web.Response instead of StreamResponse due to the following error: - - navigator.exceptions.exceptions: Error Getting Zammad Attachment: object of type '_io.BytesIO' has no len() - - Updated the headers and response handling to fix the issue. - """ - return Response(body=image, content_type=content_type) + if not content_type.startswith('image/'): + raise ConfigError("The attachment is not a valid image file.") + + content_disposition = response.headers.get('Content-Disposition') + if not content_disposition or 'filename=' not in content_disposition: + raise ConfigError("Attachment filename missing in response headers.") + + # Extract the filename from Content-Disposition + image_name = content_disposition.split('filename=')[-1].strip('"') + + # Convert BytesIO to binary data if necessary + if isinstance(image, BytesIO): + image_data = image.getvalue() + else: + image_data = image # Use as-is if already binary data + + # Construct and return the HTTP response + return Response( + body=image_data, + headers={ + 'Content-Type': content_type, + 'Content-Disposition': f'attachment; filename="{image_name}"', + 'Content-Length': str(len(image_data)), + 'Content-Transfer-Encoding': 'binary', + } + ) + except KeyError as e: + raise ConfigError(f"Missing required header: {e}") from e except Exception as e: - raise ConfigError(f"Error Getting Zammad Attachment: {e}") from e + raise ConfigError(f"Unexpected error while fetching attachment: {e}") from e From 97a9bb74f978fb0acaa6d97cd217ee2394480b8f Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Fri, 13 Dec 2024 18:15:03 -0500 Subject: [PATCH 07/11] Changes made to avoid Git warnings. --- navigator/actions/zammad.py | 295 +++++++++++++++++++++++++++++++++--- 1 file changed, 274 insertions(+), 21 deletions(-) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index 085cdfeb..02fbc390 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -17,6 +17,7 @@ from io import BytesIO + class Zammad(AbstractTicket, RESTAction): """Zammad. @@ -48,7 +49,12 @@ def __init__(self, *args, **kwargs): } async def get_user_token(self): - """Retrieve a user token using X-On-Behalf-Of for API interactions.""" + """get_user_token. + + + Usage: using X-On-Behalf-Of to getting User Token. + + """ self.url = f"{self.zammad_instance}api/v1/user_access_token" self.method = 'post' permissions: list = self._kwargs.pop('permissions', []) @@ -56,7 +62,7 @@ async def get_user_token(self): token_name = self._kwargs.pop('token_name') self.headers['X-On-Behalf-Of'] = user self.accept = 'application/json' - # Create payload for access token + ## create payload for access token: data = {**self.permissions_base, **{ "name": token_name, permissions: permissions @@ -67,53 +73,59 @@ async def get_user_token(self): return result['token'] async def list_tickets(self, **kwargs): - """Retrieve a list of all opened tickets by the user.""" + """list_tickets. + + Getting a List of all opened tickets by User. + """ self.method = 'get' - states = kwargs.pop('state_id', [1, 2, 3]) # Open by default - per_page = kwargs.pop('per_page', 100) # Max tickets count per page - page = 1 # Start with the first page + states = kwargs.pop('state_id', [1, 2, 3]) # Open by Default + per_page = kwargs.pop('per_page', 100) # Max tickets count per page + page = 1 # First Page all_tickets = [] # List for tickets - all_assets = {} # Dictionary for all assets + all_assets = {} # Dict for all assets tickets_count = 0 # Total tickets count if ',' in states: states = states.split(',') if states: - # Combine states into a query string + # Then, after getting the states, we can join them with a delimiter + # state_id: 1 OR state_id: 2 OR state_id: 3 state_id_parts = ["state_id:{}".format(state) for state in states[1:]] query_string = "state_id:{} OR ".format(states[0]) + " OR ".join(state_id_parts) qs = quote_plus(query_string) else: qs = "state_id:%201%20OR%20state_id:%202%20OR%20state_id:%203" - # Pagination loop + # Pagination Loop while True: self.url = f"{self.zammad_instance}api/v1/tickets/search?query={qs}&page={page}&limit={per_page}" try: result, _ = await self.request(self.url, self.method) - # Add tickets to the list + # Get actual tickets and add to array tickets = result.get("tickets", []) if not tickets or len(tickets) == 0: - break # Exit if no more tickets on the current page + break # If there are no more tickets on the current page, exit the loop all_tickets.extend(tickets) - # Add assets to the dictionary + # Get actual assets and add to dict assets = result.get("assets", {}) for key, value in assets.items(): if key not in all_assets: all_assets[key] = value else: + # If is a list if isinstance(value, list): all_assets[key].extend(value) + # If is a dict elif isinstance(value, dict): all_assets[key].update(value) else: all_assets[key] = value - page += 1 # Move to the next page + page += 1 # Next page except Exception as e: raise ConfigError( @@ -127,6 +139,244 @@ async def list_tickets(self, **kwargs): "assets": all_assets } + async def update(self, ticket: int, **kwargs): + """update. + + Update an Existing Ticket. + """ + self.method = 'put' + title = self._kwargs.pop('title', None) + customer = self._kwargs.pop('customer', ZAMMAD_DEFAULT_CUSTOMER) + group = self._kwargs.pop('group', ZAMMAD_DEFAULT_GROUP) + self.ticket = self._kwargs.pop('ticket', ticket) + ticket_type = self._kwargs.pop('type', 'note') + service_catalog = self._kwargs.pop( + 'service_catalog', + ZAMMAD_DEFAULT_CATALOG + ) + user = self._kwargs.pop('user', None) + if user: + self.headers['X-On-Behalf-Of'] = user + if not self.ticket: + raise ConfigError( + "Ticket ID is required." + ) + self.url = f"{self.zammad_instance}api/v1/tickets/{self.ticket}" + article = { + "subject": self._kwargs.pop('subject', title), + "body": self._kwargs.pop('body', None), + "type": ticket_type, + "internal": True + } + data = { + "title": title, + "group": group, + "customer": customer, + "service_catalog": service_catalog, + "article": article, + **kwargs + } + data = self._encoder.dumps(data) + try: + result, _ = await self.request( + self.url, self.method, data=data + ) + return result + except Exception as e: + raise ConfigError( + f"Error Updating Zammad Ticket: {e}" + ) from e + + async def create(self, **kwargs): + """create. + + Create a new Ticket. + """ + supported_types = [ + 'text/plain', 'image/png', 'image/jpeg', 'image/gif', 'application/pdf', + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv' + ] + self.url = f"{self.zammad_instance}api/v1/tickets" + self.method = 'post' + group = self._kwargs.pop('group', ZAMMAD_DEFAULT_GROUP) + title = self._kwargs.pop('title', None) + service_catalog = self._kwargs.pop('service_catalog', None) + customer = self._kwargs.pop('customer', ZAMMAD_DEFAULT_CUSTOMER) + _type = self._kwargs.pop('type', 'Incident') + user = self._kwargs.pop('user', None) + attachments = [] + for attachment in self._kwargs.get('attachments', []): + mime_type = attachment.get('mime_type') + encoded_data = attachment.get('data') + if not mime_type: + try: + # Decode the Base64-encoded data to get the binary content + binary_data = base64.b64decode(encoded_data) + # Use python-magic to determine the file's MIME type + mime_type = magic.from_buffer(binary_data, mime=True) + except Exception: + mime_type = 'text/plain' + attach = { + "mime-type": mime_type, + "filename": attachment['filename'], + "data": encoded_data + } + if mime_type in supported_types: + attachments.append(attach) + if user: + self.headers['X-On-Behalf-Of'] = user + article = { + "subject": self._kwargs.pop('subject', title), + "body": self._kwargs.pop('body', None), + "type": self._kwargs.pop('article_type', 'note'), + } + article = {**self.article_base, **article} + if attachments: + article['attachments'] = attachments + data = { + "title": title, + "group": group, + "customer": customer, + "type": _type, + "service_catalog": service_catalog, + "article": article, + **kwargs + } + try: + result, error = await self.request( + self.url, self.method, data=data + ) + if error is not None: + msg = error['message'] + raise ConfigError( + f"Error creating Zammad Ticket: {msg}" + ) + return result + except Exception as e: + raise ConfigError( + f"Error creating Zammad Ticket: {e}" + ) from e + + async def create_user(self): + """create_user. + + Create a new User. + + TODO: Adding validation with dataclasses. + """ + self.url = f"{self.zammad_instance}api/v1/users" + self.method = 'post' + organization = self._kwargs.pop( + 'organization', + ZAMMAD_ORGANIZATION + ) + roles = self._kwargs.pop('roles', [ZAMMAD_DEFAULT_ROLE]) + if not isinstance(roles, list): + roles = [ + "Customer" + ] + data = { + "organization": organization, + "roles": roles, + **self._kwargs + } + try: + result, error = await self.request( + self.url, self.method, data=data + ) + if error is not None: + msg = error['message'] + raise ConfigError( + f"Error creating User: {msg}" + ) + return result + except Exception as e: + raise ConfigError( + f"Error creating Zammad User: {e}" + ) from e + + async def find_user(self, search: dict = None): + """find_user. + + Find existing User on Zammad. + + TODO: Adding validation with dataclasses. + """ + self.url = f"{self.zammad_instance}api/v1/users/search" + self.method = 'get' + search = self._kwargs.pop('search', search) + if not isinstance(search, dict): + raise ConfigError( + f"Search Dictionary is required, current: {search}" + ) + # Joining all key:value pairs with a delimiter + query_string = ','.join( + f"{key}:{value}" for key, value in search.items() + ) + query_string = f"query={query_string}" + self.url = self.build_url( + self.url, + queryparams=query_string + ) + try: + result, _ = await self.request( + self.url, self.method + ) + return result + except ConfigError: + raise + except Exception as e: + raise ConfigError( + f"Error Searching Zammad User: {e}" + ) from e + + async def get_ticket(self, ticket_id: dict = None): + """get_ticket. + + Get a Ticket on Zammad. + + TODO: Adding validation with dataclasses. + """ + self.url = f"{self.zammad_instance}/api/v1/tickets/{ticket_id}" + self.method = 'get' + try: + result, _ = await self.request( + self.url, self.method + ) + return result + except Exception as e: + raise ConfigError( + f"Error Getting Zammad Ticket: {e}" + ) from e + + async def get_articles(self, ticket_id: int): + """get_articles + + get all articles of a ticket + + Args: + ticket_id (int): id of ticket + """ + self.url = f"{self.zammad_instance}/api/v1/ticket_articles/by_ticket/{ticket_id}" + self.method = 'get' + try: + """ + In the `articles` array returned by the URL `/api/v1/ticket_articles/by_ticket/{ticket_id}`, + if any item contains the `attachments` attribute, it should be destructured in the frontend + to request the images using `get_attachment_img`. + """ + result, _ = await self.request( + self.url, self.method + ) + return result + except Exception as e: + raise ConfigError( + f"Error Getting Zammad Ticket: {e}" + ) from e + + async def get_attachment_img(self, attachment: str): """Retrieve an attachment from a ticket. @@ -139,22 +389,23 @@ async def get_attachment_img(self, attachment: str): Raises: ConfigError: If an error occurs during the request or processing. """ + # Construir la URL para obtener el adjunto self.url = f"{self.zammad_instance}/api/v1/ticket_attachment{attachment}" self.method = 'get' self.file_buffer = True try: - # Perform the request to retrieve the attachment + # Realizar la solicitud al servidor result, error = await self.request(self.url, self.method) - # Handle errors in the response + # Manejar errores en la respuesta if error: raise ConfigError(f"Error Getting Zammad Attachment: {error.get('message', 'Unknown error')}") - # Separate the binary image data and the response headers + # Separar el cuerpo y la respuesta image, response = result - # Validate and retrieve headers + # Validar y obtener encabezados content_type = response.headers.get('Content-Type', 'application/octet-stream') if not content_type.startswith('image/'): raise ConfigError("The attachment is not a valid image file.") @@ -163,16 +414,16 @@ async def get_attachment_img(self, attachment: str): if not content_disposition or 'filename=' not in content_disposition: raise ConfigError("Attachment filename missing in response headers.") - # Extract the filename from Content-Disposition + # Extraer el nombre del archivo desde Content-Disposition image_name = content_disposition.split('filename=')[-1].strip('"') - # Convert BytesIO to binary data if necessary + # Convertir el flujo de bytes en datos completos si es necesario if isinstance(image, BytesIO): image_data = image.getvalue() else: - image_data = image # Use as-is if already binary data + image_data = image # Ya es un objeto binario válido - # Construct and return the HTTP response + # Crear y devolver la respuesta HTTP return Response( body=image_data, headers={ @@ -186,3 +437,5 @@ async def get_attachment_img(self, attachment: str): raise ConfigError(f"Missing required header: {e}") from e except Exception as e: raise ConfigError(f"Unexpected error while fetching attachment: {e}") from e + + From 269bd2faefcc6b5fd8b0b7b5c0ef92c905f895a9 Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Mon, 16 Dec 2024 18:38:05 -0500 Subject: [PATCH 08/11] The response was changed to a stream response to be used in an tag --- navigator/actions/rest.py | 11 ----------- navigator/actions/zammad.py | 29 ++++++++++++++++++++--------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/navigator/actions/rest.py b/navigator/actions/rest.py index 5b8f0bd8..1dea42f3 100644 --- a/navigator/actions/rest.py +++ b/navigator/actions/rest.py @@ -342,17 +342,6 @@ async def process_request(self, future, url: str): result = filename # getting the result, based on the Accept logic elif self.file_buffer is True: - """ - Changed response.read() to response.content to fix the following error: - - Traceback (most recent call last): - File "/home/ubuntu/navigator-api/.venv/lib/python3.11/site-packages/navigator/actions/rest.py", line 345, in process_request - data = await response.read() - ^^^^^^^^^^^^^ - AttributeError: 'Response' object has no attribute 'read' - - During handling of the above exception, another exception occurred: - """ data = response.content buffer = BytesIO(data) buffer.seek(0) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index 02fbc390..83c23117 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -1,6 +1,9 @@ import base64 import magic +from datetime import datetime, timedelta from urllib.parse import quote_plus +from aiohttp.web import Request, StreamResponse +from io import BytesIO from ..exceptions import ConfigError from ..conf import ( ZAMMAD_INSTANCE, @@ -13,8 +16,8 @@ ) from .ticket import AbstractTicket from .rest import RESTAction -from aiohttp.web import Response -from io import BytesIO + + @@ -377,7 +380,7 @@ async def get_articles(self, ticket_id: int): ) from e - async def get_attachment_img(self, attachment: str): + async def get_attachment_img(self, attachment: str, request: Request): """Retrieve an attachment from a ticket. Args: @@ -390,7 +393,7 @@ async def get_attachment_img(self, attachment: str): ConfigError: If an error occurs during the request or processing. """ # Construir la URL para obtener el adjunto - self.url = f"{self.zammad_instance}/api/v1/ticket_attachment{attachment}" + self.url = f"{self.zammad_instance}api/v1/ticket_attachment{attachment}" self.method = 'get' self.file_buffer = True @@ -423,19 +426,27 @@ async def get_attachment_img(self, attachment: str): else: image_data = image # Ya es un objeto binario válido + expiring_date = datetime.now() + timedelta(days=2) # Crear y devolver la respuesta HTTP - return Response( - body=image_data, + response = StreamResponse( + status=200, headers={ 'Content-Type': content_type, 'Content-Disposition': f'attachment; filename="{image_name}"', - 'Content-Length': str(len(image_data)), 'Content-Transfer-Encoding': 'binary', + 'Transfer-Encoding': 'chunked', + 'Connection': 'keep-alive', + "Content-Description": "File Transfer", + "Content-Transfer-Encoding": "binary", + 'Expires': expiring_date.strftime('%a, %d %b %Y %H:%M:%S GMT'), } ) + response.content_length = len(image_data) + await response.prepare(request) + await response.write(image_data) + await response.write_eof() + return response except KeyError as e: raise ConfigError(f"Missing required header: {e}") from e except Exception as e: raise ConfigError(f"Unexpected error while fetching attachment: {e}") from e - - From 71bcbb20482b5bfd1191f96bc8045185d41bb630 Mon Sep 17 00:00:00 2001 From: phenobarbital Date: Tue, 17 Dec 2024 00:56:19 +0100 Subject: [PATCH 09/11] Update version.py --- navigator/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigator/version.py b/navigator/version.py index d0fb2f99..6832bcb6 100644 --- a/navigator/version.py +++ b/navigator/version.py @@ -4,7 +4,7 @@ __description__ = ( "Navigator Web Framework based on aiohttp, " "with batteries included." ) -__version__ = "2.12.6" +__version__ = "2.12.7" __author__ = "Jesus Lara" __author_email__ = "jesuslarag@gmail.com" __license__ = "BSD" From eb33597bcddc51633ff9630508b2095667e34bbb Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Wed, 18 Dec 2024 19:23:42 -0500 Subject: [PATCH 10/11] The error 'Parse Error: Invalid character in chunk size' occurs because both response.content_length and 'Transfer-Encoding': 'chunked' are being set in the server response. This creates a conflict, as Transfer-Encoding: chunked does not require a Content-Length. By removing response.content_length = len(image_data), this conflict is avoided, and the error is resolved. --- navigator/actions/zammad.py | 1 - 1 file changed, 1 deletion(-) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index 83c23117..03ed3896 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -441,7 +441,6 @@ async def get_attachment_img(self, attachment: str, request: Request): 'Expires': expiring_date.strftime('%a, %d %b %Y %H:%M:%S GMT'), } ) - response.content_length = len(image_data) await response.prepare(request) await response.write(image_data) await response.write_eof() From 9fff4c02374ad032d585164493a9b6ad548ec3b9 Mon Sep 17 00:00:00 2001 From: Roimar Rafael Urbano Date: Wed, 18 Dec 2024 20:58:22 -0500 Subject: [PATCH 11/11] Added file scaling system with Transfer-Encoding: chunked and resolved the conflict by removing the use of content_length in the response. --- navigator/actions/zammad.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/navigator/actions/zammad.py b/navigator/actions/zammad.py index 03ed3896..10809b49 100644 --- a/navigator/actions/zammad.py +++ b/navigator/actions/zammad.py @@ -427,6 +427,8 @@ async def get_attachment_img(self, attachment: str, request: Request): image_data = image # Ya es un objeto binario válido expiring_date = datetime.now() + timedelta(days=2) + chunk_size = 16384 + content_length = len(image_data) # Crear y devolver la respuesta HTTP response = StreamResponse( status=200, @@ -441,11 +443,24 @@ async def get_attachment_img(self, attachment: str, request: Request): 'Expires': expiring_date.strftime('%a, %d %b %Y %H:%M:%S GMT'), } ) - await response.prepare(request) - await response.write(image_data) - await response.write_eof() - return response + response.headers[ + "Content-Range" + ] = f"bytes 0-{chunk_size}/{content_length}" + try: + i = 0 + await response.prepare(request) + while True: + chunk = image_data[i: i + chunk_size] + i += chunk_size + if not chunk: + break + await response.write(chunk) + await response.drain() # deprecated + await response.write_eof() + return response + except Exception as e: + raise ConfigError(f"Error while writing attachment: {e}") from e except KeyError as e: raise ConfigError(f"Missing required header: {e}") from e except Exception as e: - raise ConfigError(f"Unexpected error while fetching attachment: {e}") from e + raise ConfigError(f"Unexpected error while fetching attachment: {e}") from e \ No newline at end of file