diff --git a/README.rst b/README.rst index 6916b29..30fa8db 100644 --- a/README.rst +++ b/README.rst @@ -6,19 +6,18 @@ Let you access the OTRS API a pythonic-way. Features -------- -- Implements fully communication with the ``GenericTicketConnector`` +- Implements fully communication with the ``GenericTicketConnectorSOAP`` and ``GenericFAQConnectorSOAP`` provided as webservice example by OTRS; -- dynamic fields and attachments are supported; -- authentication is handled programmatically, per-request or per-session; -- calls are wrapped in OTRSClient methods; -- OTRS XML objects are mapped to Python-style objects see - objects.Article and objects.Ticket. +- Dynamic fields and attachments are supported; +- Authentication is handled programmatically, per-request or per-session; +- Calls are wrapped in OTRSClient methods; +- OTRS XML objects are mapped to Python-style objects. To be done ---------- - Test for python3 compatibility and make resulting changes; -- improve and extend ``tests.py``; +- Improve and extend ``tests.py``. Compatibility -------- @@ -31,33 +30,37 @@ Install pip install python-otrs -Using ------ +Ticket and Session Operations +----------------------------- -First make sure you installed the ``GenericTicketConnector`` webservice, -see `official documentation`_. +First make sure you installed the ``GenericTicketConnectorSOAP`` webservice, +see `official documentation`_. The file GenericTicketConnectorSOAP.yml can be downloaded +online as the basis for this service. + +Note: in older versions of OTRS, GenericTicketConnectorSOAP was called GenericTicketConnector :: - from otrs.client import GenericTicketConnector - from otrs.objects import Ticket, Article, DynamicField, Attachment + from otrs.ticket.template import GenericTicketConnectorSOAP + from otrs.client import GenericInterfaceClient + from otrs.ticket.objects import Ticket, Article, DynamicField, Attachment server_uri = r'https://otrs.example.net' - webservice_name = 'GenericTicketConnector' - client = GenericTicketConnector(server_uri, webservice_name) + webservice_name = 'GenericTicketConnectorSOAP' + client = GenericInterfaceClient(server_uri, tc=GenericTicketConnectorSOAP(webservice_name)) Then authenticate, you have three choices : :: # user session - client.user_session_register('login', 'password') + client.tc.SessionCreate(user_login='login', password='password') # customer_user session - client.customer_user_session_register('login' , 'password') + client.tc.SessionCreate(customer_user_login='login' , password='password') # save user in memory - client.register_credentials(user='login', 'password') + client.register_credentials(user='login', password='password') Play ! @@ -83,7 +86,7 @@ Create a ticket : ContentType=mimetype, Filename="image001.png") att_file.close() - t_id, t_number = client.ticket_create(t, a, [df1, df2], [att1]) + t_id, t_number = client.tc.TicketCreate(t, a, [df1, df2], [att1]) Update an article : @@ -91,30 +94,30 @@ Update an article : # changes the title of the ticket t_upd = Ticket(Title='Updated ticket') - client.ticket_update(t_id, t_upd) + client.tc.TicketUpdate(t_id, t_upd) # appends a new article (attachments optional) - new_article = Article(Subject='Moar info', Body='blabla', Charset='UTF8', + new_article = Article(Subject='More info', Body='blabla', Charset='UTF8', MimeType='text/plain') - client.update_ticket(article=new_article, attachments=None) + client.tc.TicketUpdate(article=new_article, attachments=None) Search for tickets : :: # returns all the tickets of customer 42 - tickets = client.ticket_search(CustomerID=42) + tickets = client.tc.TicketSearch(CustomerID=42) # returns all tickets in queue Support # for which Dynamic Field 'Project' starts with 'Pizza': df2 = DynamicField(Name='Project', Value='Pizza%', Operator="Like") - client.ticket_search(Queues='Support', dynamic_fields=[df_search]) + client.tc.TicketSearch(Queues='Support', dynamic_fields=[df_search]) Retrieve a ticket : :: - ticket = client.ticket_get(138, get_articles=True, get_dynamic_fields=True, get_attachments=True) + ticket = client.tc.TicketGet(138, get_articles=True, get_dynamic_fields=True, get_attachments=True) article = ticket.articles()[0] article.save_attachments(r'C:\temp') @@ -122,3 +125,108 @@ Many options are possible with requests, you can use all the options available in `official documentation`_. .. _official documentation: http://otrs.github.io/doc/manual/admin/4.0/en/html/genericinterface.html#generic-ticket-connector + +Public FAQ Operations +--------------------- + +First, make sure you have installed the open-source FAQ add-on module into your OTRS system and added the +GenericFAQConnectorSOAP web service by installing the GenericFAQConnector.yml file. + +:: + + from otrs.ticket.template import GenericTicketConnectorSOAP + from otrs.faq.template import GenericFAQConnectorSOAP + from otrs.client import GenericInterfaceClient + + client = GenericInterfaceClient('https://otrs.mycompany.com', tc=GenericTicketConnectorSOAP('GenericTicketConnectorSOAP'), fc=GenericFAQConnectorSOAP('GenericFAQConnectorSOAP')) + + # first, establish session with the TicketConnector + client.tc.SessionCreate(user_login='someotrsuser', password='p4ssw0rd') + +List FAQ Languages: + +:: + + langlist = client.fc.LanguageList() + for language in langlist: + print language.ID, language.Name + +List FAQ Categories that have Public FAQ items in them: + +:: + + catlist = client.fc.PublicCategoryList() + for category in catlist: + print category.ID, category.Name + +Retrieve a pubblic FAQ article by ID +(note: FAQ Item ID is not the same as the item number!) + +:: + + # retrieves FAQ item ID #190 with attachment contents included + myfaqitem = client.fc.PublicFAQGet(190, get_attachments=True) + # print the FAQ's Problem field + print myfaqitem.Field2 + # saves attachments to folder ./tempattach + myfaqitem.save_attachments('./tempattach') + +Search for an FAQ article + +:: + + #find all FAQ articles with Windows in title: + results = client.fc.PublicFAQSearch(Title='*Windows*') + for faqitemid in results: + print "Found FAQ item ID containing Windows: " + str(faqitemid) + + +Custom Web Service Connectors +----------------------------- + +For the FAQ operations above, note that we still needed the Ticket connector to provide access +to the SessionCreate method. However, if your application only needs to work with FAQ articles +and not tickets, you may wish to create a custom web service in OTRS that not only includes +the four FAQ operations but also includes the SessionCreate operation to allow you to establish +a session. This is very easy to accommodate in python-otrs. + +First, in OTRS, do the following: + +1. In OTRS Admin->Web Services, add a new web service without using a .yml file. Name it something + like 'ImprovedFAQConnectorSOAP'. +2. In the settings for the web service, set the transport to HTTP::SOAP +3. Click Save +4. Click the 'Configure' button that has appeared next to HTTP::SOAP +5. Set the namespace name to whatever you want (ex. http://www.otrs.org/FAQConnector). +6. Enter the maximum message length you want (normally 10000000) +7. Save the changes and go back to the main web service configuration screen. +8. Add the operations you want to your custom webservice. For instance, for our improved FAQConnector, + you might add the four FAQ Operations and also the SessionCreate operation. +9. Save your webservice + +Now that we have a web service in OTRS, we can use our custom web service in python-otrs. To do this, +first create a 'template' for your new ImprovedFAQConnectorSOAP. Specify the namespace name assigned +in step 5 above as the second parameter to the WebService() call. + +:: + + from otrs.faq.operations import LanguageList,PublicCategoryList,PublicFAQGet,PublicFAQSearch + from otrs.session.operations import SessionCreate + from otrs.client import WebService + + def ImprovedFAQConnectorSOAP(webservice_name='ImprovedFAQConnectorSOAP'): + return WebService(webservice_name, 'http://www.otrs.org/FAQConnector', SessionCreate=SessionCreate(), LanguageList=LanguageList(),PublicCategoryList=PublicCategoryList(),PublicFAQGet=PublicFAQGet(),PublicFAQSearch=PublicFAQSearch()) + +Now, use your improved FAQ connector: + +:: + + from otrs.client import GenericInterfaceClient + + client = GenericInterfaceClient('https://otrs.mycompany.com', impfaqc=ImprovedFAQConnectorSOAP('ImprovedFAQConnectorSOAP')) + + # first, establish session + client.impfaqc.SessionCreate(user_login='someotrsuser', password='p4ssw0rd') + + # get an FAQ item: + client.impfaqc.PublicFAQGet(190) \ No newline at end of file diff --git a/otrs/client.py b/otrs/client.py index 339589c..f33fd53 100644 --- a/otrs/client.py +++ b/otrs/client.py @@ -1,46 +1,64 @@ +"""OTRS :: client.""" try: import urllib.request as urllib2 except ImportError: import urllib2 from posixpath import join as urljoin import xml.etree.ElementTree as etree -from .objects import Ticket, OTRSObject, DynamicField, extract_tagname +from otrs.objects import OTRSObject, extract_tagname import codecs import sys +import abc class OTRSError(Exception): + """Base class for OTRS Errors.""" + def __init__(self, fd): + """Initialize OTRS Error.""" self.code = fd.getcode() self.msg = fd.read() def __str__(self): + """Return error message for OTRS Error.""" return '{} : {}'.format(self.code, self.msg) class SOAPError(OTRSError): + """OTRS Error originating from an incorrect SOAP request.""" + def __init__(self, tag): + """Initialize OTRS SOAPError.""" d = {extract_tagname(i): i.text for i in tag.getchildren()} self.errcode = d['ErrorCode'] self.errmsg = d['ErrorMessage'] def __str__(self): + """Return error message for OTRS SOAPError.""" return '{} ({})'.format(self.errmsg, self.errcode) class NoCredentialsException(OTRSError): + """OTRS Error that is returned when no credentials are provided.""" + def __init__(self): + """Initialize OTRS NoCredentialsException.""" pass def __str__(self): + """Return error message for OTRS NoCredentialsException.""" return 'Register credentials first with register_credentials() method' class WrongOperatorException(OTRSError): + """OTRS Error that is returned when a non-existent operation is called.""" + def __init__(self): + """Initialize OTRS WrongOperatorException.""" pass def __str__(self): + """Return error message for OTRS WrongOperatorException.""" return '''Please use one of the following operators for the query on a dynamic field: `Equals`, `Like`, `GreaterThan`, `GreaterThanEquals`, `SmallerThan` or `SmallerThanEquals`. @@ -48,9 +66,7 @@ def __str__(self): def authenticated(func): - """ Decorator to add authentication parameters to a request - """ - + """Decorator to add authentication parameters to a request.""" def add_auth(self, *args, **kwargs): if self.session_id: kwargs['SessionID'] = self.session_id @@ -65,46 +81,79 @@ def add_auth(self, *args, **kwargs): return add_auth -SOAP_ENVELOPPE = """ - - - {} - -""" +class OperationBase(object): + """Base class for OTRS operations.""" + __metaclass__ = abc.ABCMeta -class GenericTicketConnector(object): - """ Client for the GenericTicketConnector SOAP API - - see http://otrs.github.io/doc/manual/admin/3.3/en/html/genericinterface.html - """ - - def __init__(self, server, webservice_name='GenericTicketConnector', ssl_context=None): - """ @param server : the http(s) URL of the root installation of OTRS - (e.g: https://tickets.example.net) - - @param webservice_name : the name of the installed webservice - (choosen by the otrs admin). - """ - - self.endpoint = urljoin( - server, 'otrs/nph-genericinterface.pl/Webservice/', - webservice_name) - self.login = None - self.password = None - self.session_id = None - self.ssl_context = ssl_context - - def register_credentials(self, login, password): - """ Save the identifiers in memory, they will be used with each - subsequent request requiring authentication - """ - self.login = login - self.password = password + def __init__(self, opName=None): + """Initialize OperationBase.""" + if opName is None: + self.operName = type(self).__name__ + else: + self.operName = opName # otrs connector operation name + self.wsObject = None # web services object this operation belongs to + + def getWebServiceObjectAttribute(self, attribName): + """Return attribute of the WebService object.""" + return getattr(self.wsObject, attribName) + + def getClientObjectAttribute(self, attribName): + """Return attribute of the clientobject of the WebService object.""" + return self.wsObject.getClientObjectAttribute(attribName) + + def setClientObjectAttribute(self, attribName, attribValue): + """Set attribute of the clientobject of the WebService object.""" + self.wsObject.setClientObjectAttribute(attribName, attribValue) + + @abc.abstractmethod + def __call__(self): + """.""" + return + + @property + def endpoint(self): + """Return endpoint of WebService object.""" + return self.getWebServiceObjectAttribute('endpoint') + + @property + def login(self): + """Get login attribute of the clientobject of the WebService object.""" + return self.getClientObjectAttribute('login') + + @property + def password(self): + """Return password attribute of the clientobject of the WebService.""" + return self.getClientObjectAttribute('password') + + @property + def ssl_context(self): + """Return ssl_context of the clientobject of the WebService.""" + return self.getClientObjectAttribute('ssl_context') + + @property + def session_id(self): + """Return session_id of the clientobject of the WebService object.""" + return self.getClientObjectAttribute('session_id') + + @session_id.setter + def session_id(self, sessionid): + """Set session_id of the clientobject of the WebService object.""" + self.setClientObjectAttribute('session_id', sessionid) + + @property + def soap_envelope(self): + """Return soap envelope for WebService object.""" + soap_envelope = '{}' + \ + '' + return soap_envelope def req(self, reqname, *args, **kwargs): - """ Wrapper arround a SOAP request + """Wrapper arround a SOAP request. + @param reqname: the SOAP name of the request @param kwargs : to define the tags included in the request. @return : the full etree.Element of the response @@ -115,7 +164,6 @@ def req(self, reqname, *args, **kwargs): - list of `OTRSObject`s: each `OTRSObject`s in the list will be serialized with their `.to_xml()` (used for dynamic fields and attachments). - """ xml_req_root = etree.Element(reqname) @@ -135,7 +183,8 @@ def req(self, reqname, *args, **kwargs): self.endpoint, self._pack_req(xml_req_root), {'Content-Type': 'text/xml;charset=utf-8'}) - if (sys.version_info[0] == 3 and sys.version_info < (3,4,3)) or sys.version_info < (2,7,9): + if ((sys.version_info[0] == 3 and sys.version_info < (3, 4, 3)) or + (sys.version_info < (2, 7, 9))): fd = urllib2.urlopen(request) else: fd = urllib2.urlopen(request, context=self.ssl_context) @@ -160,7 +209,8 @@ def req(self, reqname, *args, **kwargs): @staticmethod def _unpack_resp_several(element): - """ + """Unpack an etree element and return a list of children. + @param element : a etree.Element @return : a list of etree.Element """ @@ -168,191 +218,175 @@ def _unpack_resp_several(element): @staticmethod def _unpack_resp_one(element): - """ + """Unpack an etree element an return first child. + @param element : a etree.Element @return : a etree.Element (first child of the response) """ return element.getchildren()[0].getchildren()[0].getchildren()[0] - @staticmethod - def _pack_req(element): - """ + def _pack_req(self, element): + """Pack an etree Element. + @param element : a etree.Element @returns : a string, wrapping element within the request tags + """ + return self.soap_envelope.format( + codecs.decode(etree.tostring(element), 'utf-8')).encode('utf-8') + + +class WebService(object): + """Base class for OTRS Web Service.""" + + def __init__(self, wsName, wsNamespace, **kwargs): + """Initialize WebService object.""" + self.clientObject = None # link to parent client object + self.wsName = wsName # name for OTRS web service + self.wsNamespace = wsNamespace # OTRS namespace url + + # add all variables in kwargs into the local dictionary + self.__dict__.update(kwargs) + + # for operations, set backlinks to their associated webservice + for arg in kwargs: + # if attribute is type OperationBase, set backlink to WebService + if isinstance(getattr(self, arg), OperationBase): + getattr(self, arg).wsObject = self + + # set defaults if attributes are not present + if not hasattr(self, 'wsRequestNameScheme'): + self.wsRequestNameScheme = 'DATA' + if not hasattr(self, 'wsResponseNameScheme'): + ns = 'DATA' + self.wsResponseNameScheme = ns + + def getClientObjectAttribute(self, attribName): + """Return attribute of the clientobject of the WebService object.""" + return getattr(self.clientObject, attribName) + + def setClientObjectAttribute(self, attribName, attribValue): + """Set attribute of the clientobject of the WebService object.""" + setattr(self.clientObject, attribName, attribValue) + + @property + def endpoint(self): + """Return endpoint of WebService object.""" + return urljoin(self.getClientObjectAttribute('giurl'), self.wsName) + +class GenericInterfaceClient(object): + """Client for the OTRS Generic Interface.""" + + def __init__(self, server, ssl_context=None, **kwargs): + """Initialize GenericInterfaceClient. + + @param server : the http(s) URL of the root installation of OTRS + (e.g: https://tickets.example.net) """ - return SOAP_ENVELOPPE.format(codecs.decode(etree.tostring(element),'utf-8')).encode('utf-8') + # add all variables in kwargs into the local dictionary + self.__dict__.update(kwargs) + + # for webservices attached to this client object, backlink them + # to this client object to allow access to session login/password + + for arg in kwargs: + # set backlink for web services to this obj + if isinstance(getattr(self, arg), WebService): + getattr(self, arg).clientObject = self + self.login = None + self.password = None + self.session_id = None + self.ssl_context = ssl_context + self.giurl = urljoin( + server, 'otrs/nph-genericinterface.pl/Webservice/') + + def register_credentials(self, login, password): + """Save the identifiers in memory. + + They will be used with each subsequent request requiring authentication + """ + self.login = login + self.password = password + + +class OldGTCClass(GenericInterfaceClient): + """DEPRECATED - Old generic ticket connector class. + + Used for backward compatibility with previous versions. All + methods in here are deprecated. + """ def session_create(self, password, user_login=None, - customer_user_login=None): - """ Logs the user or customeruser in + customer_user_login=None): + """DEPRECATED - creates a session for an User or CustomerUser. @returns the session_id """ - if user_login: - ret = self.req('SessionCreate', - UserLogin=user_login, - Password=password) - else: - ret = self.req('SessionCreate', - CustomerUserLogin=customer_user_login, - Password=password) - signal = self._unpack_resp_one(ret) - session_id = signal.text - return session_id + self.tc.SessionCreate(password, user_login=user_login, + customer_user_login=customer_user_login) def user_session_register(self, user, password): - """ Logs the user in and stores the session_id for subsequent requests - """ - self.session_id = self.session_create( - password=password, - user_login=user) + """DEPRECATED - creates a session for an User.""" + self.session_create(password=password, user_login=user) def customer_user_session_register(self, user, password): - """ Logs the customer_user in and stores the session_id for subsequent - requests. - """ - self.session_id = self.session_create( - password=password, - customer_user_login=user) + """DEPRECATED - creates a session for a CustomerUser.""" + self.session_create(password=password, customer_user_login=user) @authenticated - def ticket_get(self, ticket_id, get_articles=False, - get_dynamic_fields=False, - get_attachments=False, *args, **kwargs): - """ Get a ticket by id ; beware, TicketID != TicketNumber - - @param ticket_id : the TicketID of the ticket - @param get_articles : grab articles linked to the ticket - @param get_dynamic_fields : include dynamic fields in result - @param get_attachments : include attachments in result - - @return a `Ticket`, Ticket.articles() will give articles if relevant. - Ticket.articles()[i].attachments() will return the attachments for - an article, wheres Ticket.articles()[i].save_attachments() - will save the attachments of article[i] to the specified folder. - """ - params = {'TicketID': str(ticket_id)} - params.update(kwargs) - if get_articles: - params['AllArticles'] = True - if get_dynamic_fields: - params['DynamicFields'] = True - if get_attachments: - params['Attachments'] = True - - ret = self.req('TicketGet', **params) - return Ticket.from_xml(self._unpack_resp_one(ret)) + def ticket_create(self, ticket, article, dynamic_fields=None, + attachments=None, **kwargs): + """DEPRECATED - now calls operation of GenericTicketConnectorSOAP.""" + return self.tc.TicketCreate(ticket, + article, + dynamic_fields=dynamic_fields, + attachments=attachments, + **kwargs) @authenticated - def ticket_search(self, dynamic_fields=None, **kwargs): - """ - @param dynamic_fields a list of Dynamic Fields, in addition to - the combination of `Name` and `Value`, also an `Operator` for the - comparison is expexted `Equals`, `Like`, `GreaterThan`, - `GreaterThanEquals`, `SmallerThan` or `SmallerThanEquals`. - The `Like` operator accepts a %-sign as wildcard. - @returns a list of matching TicketID - """ - df_search_list = [] - dynamic_field_requirements = ('Name', 'Value', 'Operator') - if not (dynamic_fields is None): - for df in dynamic_fields: - df.check_fields(dynamic_field_requirements) - if df.Operator == 'Equals': - df_search = DynamicField(Equals=df.Value) - elif df.Operator == 'Like': - df_search = DynamicField(Like=df.Value) - elif df.Operator == 'GreaterThan': - df_search = DynamicField(GreaterThan=df.Value) - elif df.Operator == 'GreaterThanEquals': - df_search = DynamicField(GreaterThanEquals=df.Value) - elif df.Operator == 'SmallerThan': - df_search = DynamicField(SmallerThan=df.Value) - elif df.Operator == 'SmallerThan': - df_search = DynamicField(SmallerThan=df.Value) - else: - raise WrongOperatorException() - df_search.XML_NAME = 'DynamicField_{0}'.format(df.Name) - df_search_list.append(df_search) - kwargs['DynamicFields'] = df_search_list - - ret = self.req('TicketSearch', **kwargs) - return [int(i.text) for i in self._unpack_resp_several(ret)] + def ticket_get(self, ticket_id, get_articles=False, + get_dynamic_fields=False, get_attachments=False, + *args, **kwargs): + """DEPRECATED - now calls operation of GenericTicketConnectorSOAP.""" + return self.tc.TicketGet(ticket_id, + get_articles=get_articles, + get_dynamic_fields=get_dynamic_fields, + get_attachments=get_attachments, + *args, + **kwargs) @authenticated - def ticket_create(self, ticket, article, dynamic_fields=None, - attachments=None, **kwargs): - """ - @param ticket a Ticket - @param article an Article - @param dynamic_fields a list of Dynamic Fields - @param attachments a list of Attachments - @returns the ticketID, TicketNumber - """ - ticket_requirements = ( - ('StateID', 'State'), ('PriorityID', 'Priority'), - ('QueueID', 'Queue'), ) - article_requirements = ('Subject', 'Body', 'Charset', 'MimeType') - dynamic_field_requirements = ('Name', 'Value') - attachment_field_requirements = ('Content', 'ContentType', 'Filename') - ticket.check_fields(ticket_requirements) - article.check_fields(article_requirements) - if not (dynamic_fields is None): - for df in dynamic_fields: - df.check_fields(dynamic_field_requirements) - if not (attachments is None): - for att in attachments: - att.check_fields(attachment_field_requirements) - ret = self.req('TicketCreate', ticket=ticket, article=article, - dynamic_fields=dynamic_fields, - attachments=attachments, **kwargs) - elements = self._unpack_resp_several(ret) - infos = {extract_tagname(i): int(i.text) for i in elements} - return infos['TicketID'], infos['TicketNumber'] + def ticket_search(self, dynamic_fields=None, **kwargs): + """DEPRECATED - now calls operation of GenericTicketConnectorSOAP.""" + return self.tc.TicketSearch(dynamic_fields=dynamic_fields, **kwargs) @authenticated def ticket_update(self, ticket_id=None, ticket_number=None, ticket=None, article=None, dynamic_fields=None, attachments=None, **kwargs): - """ - @param ticket_id the ticket ID of the ticket to modify - @param ticket_number the ticket Number of the ticket to modify - @param ticket a ticket containing the fields to change on ticket - @param article a new Article to append to the ticket - @param dynamic_fields a list of Dynamic Fields to change on ticket - @param attachments a list of Attachments for a newly appended article - @returns the ticketID, TicketNumber - - - Mandatory : - `ticket_id` xor `ticket_number` - - `ticket` or `article` or `dynamic_fields` - - """ - if not (ticket_id is None): - kwargs['TicketID'] = ticket_id - elif not (ticket_number is None): - kwargs['TicketNumber'] = ticket_number - else: - raise ValueError('requires either ticket_id or ticket_number') - - if (ticket is None) and (article is None) and (dynamic_fields is None): - raise ValueError( - 'requires at least one among ticket, article, dynamic_fields') - elif (article is None) and not (attachments is None): - raise ValueError( - 'Attachments can only be created for a newly appended article') - else: - if (ticket): - kwargs['Ticket'] = ticket - if (article): - kwargs['Article'] = article - if (dynamic_fields): - kwargs['DynamicField'] = dynamic_fields - if (attachments): - kwargs['Attachment'] = attachments - - ret = self.req('TicketUpdate', **kwargs) - elements = self._unpack_resp_several(ret) - infos = {extract_tagname(i): int(i.text) for i in elements} - return infos['TicketID'], infos['TicketNumber'] + """DEPRECATED - now calls operation of GenericTicketConnectorSOAP.""" + return self.tc.TicketUpdate(ticket_id=ticket_id, + ticket_number=ticket_number, + ticket=ticket, + article=article, + dynamic_fields=dynamic_fields, + attachments=attachments, **kwargs) + + +def GenericTicketConnector(server, + webservice_name='GenericTicketConnector', + ssl_context=None): + """DEPRECATED - now calls operation of GenericTicketConnectorSOAP.""" + from otrs.ticket.operations import TicketCreate, TicketGet + from otrs.ticket.operations import TicketSearch, TicketUpdate + from otrs.session.operations import SessionCreate + ticketconnector = WebService( + webservice_name, + 'http://www.otrs.org/TicketConnector', + ssl_context=ssl_context, + SessionCreate=SessionCreate(), + TicketCreate=TicketCreate(), + TicketGet=TicketGet(), + TicketSearch=TicketSearch(), + TicketUpdate=TicketUpdate()) + return OldGTCClass(server, tc=ticketconnector) diff --git a/otrs/faq/__init__.py b/otrs/faq/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/otrs/faq/objects.py b/otrs/faq/objects.py new file mode 100644 index 0000000..df47555 --- /dev/null +++ b/otrs/faq/objects.py @@ -0,0 +1,21 @@ +"""OTRS :: faq :: objects.""" +from otrs.objects import OTRSObject, Attachment, AttachmentContainer + + +class Category(OTRSObject): + """An OTRS FAQ Category.""" + + XML_NAME = 'Category' + + +class Language(OTRSObject): + """An OTRS FAQ Language.""" + + XML_NAME = 'Language' + + +class FAQItem(OTRSObject, AttachmentContainer): + """An OTRS FAQ Item.""" + + XML_NAME = 'FAQItem' + CHILD_MAP = {'Attachment': Attachment} diff --git a/otrs/faq/operations.py b/otrs/faq/operations.py new file mode 100644 index 0000000..c67ba05 --- /dev/null +++ b/otrs/faq/operations.py @@ -0,0 +1,73 @@ +"""OTRS :: faq :: operations.""" +from otrs.faq.objects import Category as CategoryObject +from otrs.faq.objects import Language as LanguageObject +from otrs.faq.objects import FAQItem as FAQItemObject +from otrs.client import OperationBase, authenticated + + +class FAQ(OperationBase): + """Base class for OTRS FAQ:: operations.""" + + +class LanguageList(FAQ): + """Class to handle OTRS ITSM FAQ::LanguageList operation.""" + + @authenticated + def __call__(self, *args, **kwargs): + """Return the Language List from FAQ. + + @returns list of languages + """ + ret = self.req('LanguageList', **kwargs) + elements = self._unpack_resp_several(ret) + return [LanguageObject.from_xml(language) for language in elements] + + +class PublicCategoryList(FAQ): + """Class to handle OTRS ITSM FAQ::PublicCategoryList operation.""" + + @authenticated + def __call__(self, **kwargs): + """Return the Public Category List from FAQ. + + @returns list of category objects + """ + ret = self.req('PublicCategoryList', **kwargs) + elements = self._unpack_resp_several(ret) + return [CategoryObject.from_xml(category) for category in elements] + + +class PublicFAQGet(FAQ): + """Class to handle OTRS ITSM FAQ::PublicFAQGet operation.""" + + @authenticated + def __call__(self, item_id, get_attachments=False, **kwargs): + """Get a public FAQItem by id. + + @param item_id : the ItemID of the public FAQItem + NOTE: ItemID != FAQ Number + + @return an `FAQItem` + """ + params = {'ItemID': str(item_id)} + params.update(kwargs) + if get_attachments: + params['GetAttachmentContents'] = 1 + else: + params['GetAttachmentContents'] = 0 + + ret = self.req('PublicFAQGet', **params) + return FAQItemObject.from_xml(self._unpack_resp_one(ret)) + + +class PublicFAQSearch(FAQ): + """Class to handle OTRS ITSM FAQ :: PublicFAQSearch operation.""" + + @authenticated + def __call__(self, *args, **kwargs): + """Search for matching public FAQItems. + + @returns a list of matching public FAQItem IDs + """ + ret = self.req('PublicFAQSearch', **kwargs) + return [int(i.text) for i in self._unpack_resp_several(ret)] diff --git a/otrs/faq/template.py b/otrs/faq/template.py new file mode 100644 index 0000000..d7cf5ed --- /dev/null +++ b/otrs/faq/template.py @@ -0,0 +1,15 @@ +"""OTRS :: faq :: template.""" +from otrs.faq.operations import LanguageList, PublicCategoryList +from otrs.faq.operations import PublicFAQGet, PublicFAQSearch +from otrs.client import WebService + +def GenericFAQConnectorSOAP(webservice_name='GenericFAQConnectorSOAP'): + """Return a GenericFAQConnectorSOAP Webservice object. + + @returns a WebService object with the GenericFAQConnectorSOAP operations + """ + return WebService(webservice_name, 'http://www.otrs.org/FAQConnector', + LanguageList=LanguageList(), + PublicCategoryList=PublicCategoryList(), + PublicFAQGet=PublicFAQGet(), + PublicFAQSearch=PublicFAQSearch()) diff --git a/otrs/objects.py b/otrs/objects.py index fc99d84..65a8c5e 100644 --- a/otrs/objects.py +++ b/otrs/objects.py @@ -1,24 +1,26 @@ +"""OTRS :: objects.""" from __future__ import unicode_literals import xml.etree.ElementTree as etree import os +import sys import base64 - class OTRSObject(object): - """ Represents an object for OTRS (mappable to an XML element) - """ + """Represents an object for OTRS (mappable to an XML element).""" # Map : {'TagName' -> Class} - CHILD_MAP = {} def __init__(self, *args, **kwargs): + """Initialize OTRS Object.""" self.attrs = kwargs self.childs = {} def __getattr__(self, k): - """ attrs are simple xml child tags (val), complex children, + """Get an attribute for aan OTRSObject. + + attrs are simple xml child tags (val), complex children, are accessible via dedicated methods. @returns a simple type @@ -27,7 +29,8 @@ def __getattr__(self, k): @classmethod def from_xml(cls, xml_element): - """ + """Create an OTRS Object from xml. + @param xml_element an etree.Element @returns an OTRSObject """ @@ -57,7 +60,8 @@ def from_xml(cls, xml_element): return obj def add_child(self, childobj): - """ + """Add a child object to an OTRS Object. + @param childobj : an OTRSObject """ xml_name = childobj.XML_NAME @@ -68,7 +72,8 @@ def add_child(self, childobj): self.childs[xml_name] = [childobj] def check_fields(self, fields): - """ Checks that the list of fields is bound + """Check that the list of fields is bound. + @param fields rules, as list items n fields can be either : @@ -92,21 +97,25 @@ def check_fields(self, fields): raise ValueError('{} should be filled'.format(i)) def to_xml(self): - """ + """Create an XML representation of an OTRS Object. + @returns am etree.Element """ root = etree.Element(self.XML_NAME) for k, v in self.attrs.items(): e = etree.Element(k) - if isinstance(e, str): # True + if isinstance(e, str): v = v.encode('utf-8') - e.text = unicode(v) + if sys.version_info[0] == 3: + e.text = str(v) + else: + e.text = unicode(v) root.append(e) return root def extract_tagname(element): - """ Returns the name of the tag, without namespace + """Return the name of the tag, without namespace. element.tag lib gives "{namespace}tagname", we want only "tagname" @@ -119,16 +128,15 @@ def extract_tagname(element): except IndexError: # if it's not namespaced, then return the tag name itself return element.tag - #raise ValueError('"{}" is not a tag name'.format(qualified_name)) + # raise ValueError('"{}" is not a tag name'.format(qualified_name)) def autocast(s): - """ Tries to guess the simple type and convert the value to it. + """Try to guess the simple type and convert the value to it. @param s string @returns the relevant type : a float, string or int """ - try: return int(s) except ValueError: @@ -139,31 +147,35 @@ def autocast(s): class Attachment(OTRSObject): + """An OTRS attachment.""" + XML_NAME = 'Attachment' class DynamicField(OTRSObject): + """An OTRS dynamic field.""" + XML_NAME = 'DynamicField' -class Article(OTRSObject): - XML_NAME = 'Article' - CHILD_MAP = {'Attachment': Attachment, 'DynamicField': DynamicField} +class AttachmentContainer(object): + """For objects that can have attachments in them (ex. tickets, articles). + + They should inherit this class in addition to OTRSObject. + """ def attachments(self): + """Return the dynamic fields for an object ket as a list. + + @returns a list of Attachment objects. + """ try: return self.childs['Attachment'] except KeyError: return [] - def dynamicfields(self): - try: - return self.childs['DynamicField'] - except KeyError: - return [] - def save_attachments(self, folder): - """ Saves the attachments of an article to the specified folder + """Save the attachments of an article to the specified folder. @param folder : a str, folder to save the attachments """ @@ -177,19 +189,35 @@ def save_attachments(self, folder): ffile.close() -class Ticket(OTRSObject): - XML_NAME = 'Ticket' - CHILD_MAP = {'Article': Article, 'DynamicField': DynamicField} +class DynamicFieldContainer(object): + """For objects that can have dynamic fields in them (ex. tickets, articles). + + They should inherit this class in addition to OTRSObject. + """ - def articles(self): - try: - return self.childs['Article'] - except KeyError: - return [] - def dynamicfields(self): + """Return the dynamic fields for an object ket as a list. + + @returns a list of DynamicField objects. + """ try: return self.childs['DynamicField'] except KeyError: return [] + +# the two functions below are here only for backward compatibility +# with old code that imported these classes from this file +# the classes are now in tickets/objects.py + + +def Ticket(*args, **kwargs): + """Return an OTRS ticket.""" + import otrs.ticket.objects + return otrs.ticket.objects.Ticket(*args, **kwargs) + + +def Article(*args, **kwargs): + """Return an OTRS article.""" + import otrs.ticket.objects + return otrs.ticket.objects.Article(*args, **kwargs) diff --git a/otrs/session/__init__.py b/otrs/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/otrs/session/operations.py b/otrs/session/operations.py new file mode 100644 index 0000000..a63219d --- /dev/null +++ b/otrs/session/operations.py @@ -0,0 +1,33 @@ +"""OTRS :: session :: operations.""" +from otrs.client import OperationBase + + +class Session(OperationBase): + """Base class for OTRS Session:: operations.""" + + +class SessionCreate(Session): + """Class to handle OTRS Session::SessionCreate operation.""" + + def __call__(self, password, user_login=None, customer_user_login=None): + """Create an User session or CustomerUser session. + + @returns the session_id + """ + if user_login: + ret = self.req('SessionCreate', + UserLogin=user_login, + Password=password) + else: + ret = self.req('SessionCreate', + CustomerUserLogin=customer_user_login, + Password=password) + signal = self._unpack_resp_one(ret) + session_id = signal.text + + # sets the session id for the entire client to this + self.session_id = session_id + + # returns the session id in case you want it, + # but its not normally needed + return session_id diff --git a/otrs/ticket/__init__.py b/otrs/ticket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/otrs/ticket/objects.py b/otrs/ticket/objects.py new file mode 100644 index 0000000..2481658 --- /dev/null +++ b/otrs/ticket/objects.py @@ -0,0 +1,27 @@ +"""OTRS :: ticket :: objects.""" +from otrs.objects import OTRSObject, Attachment, DynamicField +from otrs.objects import AttachmentContainer, DynamicFieldContainer + + +class Article(OTRSObject, AttachmentContainer, DynamicFieldContainer): + """An OTRS article.""" + + XML_NAME = 'Article' + CHILD_MAP = {'Attachment': Attachment, 'DynamicField': DynamicField} + + +class Ticket(OTRSObject, DynamicFieldContainer): + """An OTRS ticket.""" + + XML_NAME = 'Ticket' + CHILD_MAP = {'Article': Article, 'DynamicField': DynamicField} + + def articles(self): + """Return the articles for a ticket as a list. + + @returns a list of Article objects. + """ + try: + return self.childs['Article'] + except KeyError: + return [] diff --git a/otrs/ticket/operations.py b/otrs/ticket/operations.py new file mode 100644 index 0000000..a25317b --- /dev/null +++ b/otrs/ticket/operations.py @@ -0,0 +1,168 @@ +"""OTRS :: ticket :: operations.""" +from otrs.ticket.objects import Ticket as TicketObject +from otrs.client import OperationBase, authenticated, WrongOperatorException +from otrs.objects import extract_tagname, DynamicField + + +class Ticket(OperationBase): + """Base class for OTRS Ticket:: operations.""" + + +class TicketCreate(Ticket): + """Class to handle OTRS Ticket::TicketCreate operation.""" + + @authenticated + def __call__(self, ticket, article, dynamic_fields=None, + attachments=None, **kwargs): + """Create a new ticket. + + @param ticket a Ticket + @param article an Article + @param dynamic_fields a list of Dynamic Fields + @param attachments a list of Attachments + @returns the ticketID, TicketNumber + """ + ticket_requirements = ( + ('StateID', 'State'), ('PriorityID', 'Priority'), + ('QueueID', 'Queue'), ) + article_requirements = ('Subject', 'Body', 'Charset', 'MimeType') + dynamic_field_requirements = ('Name', 'Value') + attachment_field_requirements = ('Content', 'ContentType', 'Filename') + ticket.check_fields(ticket_requirements) + article.check_fields(article_requirements) + if not (dynamic_fields is None): + for df in dynamic_fields: + df.check_fields(dynamic_field_requirements) + if not (attachments is None): + for att in attachments: + att.check_fields(attachment_field_requirements) + ret = self.req('TicketCreate', ticket=ticket, article=article, + dynamic_fields=dynamic_fields, + attachments=attachments, **kwargs) + elements = self._unpack_resp_several(ret) + infos = {extract_tagname(i): int(i.text) for i in elements} + return infos['TicketID'], infos['TicketNumber'] + + +class TicketGet(Ticket): + """Class to handle OTRS Ticket::TicketGet operation.""" + + @authenticated + def __call__(self, ticket_id, get_articles=False, + get_dynamic_fields=False, + get_attachments=False, *args, **kwargs): + """Get a ticket by id ; beware, TicketID != TicketNumber. + + @param ticket_id : the TicketID of the ticket + @param get_articles : grab articles linked to the ticket + @param get_dynamic_fields : include dynamic fields in result + @param get_attachments : include attachments in result + + @return a `Ticket`, Ticket.articles() will give articles if relevant. + Ticket.articles()[i].attachments() will return the attachments for + an article, wheres Ticket.articles()[i].save_attachments() + will save the attachments of article[i] to the specified folder. + """ + params = {'TicketID': str(ticket_id)} + params.update(kwargs) + if get_articles: + params['AllArticles'] = True + if get_dynamic_fields: + params['DynamicFields'] = True + if get_attachments: + params['Attachments'] = True + + ret = self.req('TicketGet', **params) + return TicketObject.from_xml(self._unpack_resp_one(ret)) + + +class TicketSearch(Ticket): + """Class to handle OTRS Ticket::TicketSearch operation.""" + + @authenticated + def __call__(self, dynamic_fields=None, **kwargs): + """Search for a ticket by. + + @param dynamic_fields a list of Dynamic Fields, in addition to + the combination of `Name` and `Value`, also an `Operator` for the + comparison is expexted `Equals`, `Like`, `GreaterThan`, + `GreaterThanEquals`, `SmallerThan` or `SmallerThanEquals`. + The `Like` operator accepts a %-sign as wildcard. + @returns a list of matching TicketID + """ + df_search_list = [] + dynamic_field_requirements = ('Name', 'Value', 'Operator') + if not (dynamic_fields is None): + for df in dynamic_fields: + df.check_fields(dynamic_field_requirements) + if df.Operator == 'Equals': + df_search = DynamicField(Equals=df.Value) + elif df.Operator == 'Like': + df_search = DynamicField(Like=df.Value) + elif df.Operator == 'GreaterThan': + df_search = DynamicField(GreaterThan=df.Value) + elif df.Operator == 'GreaterThanEquals': + df_search = DynamicField(GreaterThanEquals=df.Value) + elif df.Operator == 'SmallerThan': + df_search = DynamicField(SmallerThan=df.Value) + elif df.Operator == 'SmallerThan': + df_search = DynamicField(SmallerThan=df.Value) + else: + raise WrongOperatorException() + df_search.XML_NAME = 'DynamicField_{0}'.format(df.Name) + df_search_list.append(df_search) + kwargs['DynamicFields'] = df_search_list + + ret = self.req('TicketSearch', **kwargs) + return [int(i.text) for i in self._unpack_resp_several(ret)] + + +class TicketUpdate(Ticket): + """Class to handle OTRS Ticket::TicketUpdate operation.""" + + @authenticated + def __call__(self, ticket_id=None, ticket_number=None, + ticket=None, article=None, dynamic_fields=None, + attachments=None, **kwargs): + """Update an existing ticket. + + @param ticket_id the ticket ID of the ticket to modify + @param ticket_number the ticket Number of the ticket to modify + @param ticket a ticket containing the fields to change on ticket + @param article a new Article to append to the ticket + @param dynamic_fields a list of Dynamic Fields to change on ticket + @param attachments a list of Attachments for a newly appended article + @returns the ticketID, TicketNumber + + + Mandatory : - `ticket_id` xor `ticket_number` + - `ticket` or `article` or `dynamic_fields` + + """ + if not (ticket_id is None): + kwargs['TicketID'] = ticket_id + elif not (ticket_number is None): + kwargs['TicketNumber'] = ticket_number + else: + raise ValueError('requires either ticket_id or ticket_number') + + if (ticket is None) and (article is None) and (dynamic_fields is None): + raise ValueError( + 'requires at least one among ticket, article, dynamic_fields') + elif (article is None) and not (attachments is None): + raise ValueError( + 'Attachments can only be created for a newly appended article') + else: + if (ticket): + kwargs['Ticket'] = ticket + if (article): + kwargs['Article'] = article + if (dynamic_fields): + kwargs['DynamicField'] = dynamic_fields + if (attachments): + kwargs['Attachment'] = attachments + + ret = self.req('TicketUpdate', **kwargs) + elements = self._unpack_resp_several(ret) + infos = {extract_tagname(i): int(i.text) for i in elements} + return infos['TicketID'], infos['TicketNumber'] diff --git a/otrs/ticket/template.py b/otrs/ticket/template.py new file mode 100644 index 0000000..1214bfa --- /dev/null +++ b/otrs/ticket/template.py @@ -0,0 +1,17 @@ +"""OTRS :: ticket:: template.""" +from otrs.ticket.operations import TicketCreate, TicketGet +from otrs.ticket.operations import TicketSearch, TicketUpdate +from otrs.session.operations import SessionCreate +from otrs.client import WebService + + +def GenericTicketConnectorSOAP(webservice_name='GenericTicketConnectorSOAP'): + """Return a GenericTicketConnectorSOAP Webservice object. + + @returns a WebService object with the GenericTicketConnectorSOAP operations + """ + return WebService(webservice_name, 'http://www.otrs.org/TicketConnector', + SessionCreate=SessionCreate(), + TicketCreate=TicketCreate(), + TicketGet=TicketGet(), TicketSearch=TicketSearch(), + TicketUpdate=TicketUpdate()) diff --git a/tests.py b/tests.py index 9d6c4c3..23968f3 100644 --- a/tests.py +++ b/tests.py @@ -2,8 +2,9 @@ import os import xml.etree.ElementTree as etree -from otrs.client import GenericTicketConnector -from otrs.objects import Ticket, Article +from otrs.client import GenericInterfaceClient +from otrs.ticket.template import GenericTicketConnectorSOAP +from otrs.ticket.objects import Ticket, Article REQUIRED_VARS = 'OTRS_LOGIN', 'OTRS_PASSWORD', 'OTRS_SERVER', 'OTRS_WEBSERVICE' MISSING_VARS = [] @@ -168,21 +169,21 @@ class TestOTRSAPI(unittest.TestCase): def setUp(self): - self.c = GenericTicketConnector(OTRS_SERVER, OTRS_WEBSERVICE) + self.c = GenericInterfaceClient(OTRS_SERVER, tc=GenericTicketConnectorSOAP(OTRS_WEBSERVICE)) self.c.register_credentials(OTRS_LOGIN, OTRS_PASSWORD) def test_session_create(self): - sessid = self.c.session_create(user_login=OTRS_LOGIN, - password=OTRS_PASSWORD) + sessid = self.c.tc.SessionCreate(user_login=OTRS_LOGIN, + password=OTRS_PASSWORD) self.assertEqual(len(sessid), 32) def test_ticket_get(self): - t = self.c.ticket_get(1) + t = self.c.tc.TicketGet(1) self.assertEqual(t.TicketID, 1) self.assertEqual(t.StateType, 'new') def test_ticket_get_with_articles(self): - t = self.c.ticket_get(1, get_articles=True) + t = self.c.tc.TicketGet(1, get_articles=True) self.assertEqual(t.TicketID, 1) self.assertEqual(t.StateType, 'new') articles = t.articles() @@ -191,7 +192,7 @@ def test_ticket_get_with_articles(self): self.assertEqual(articles[0].SenderType, 'customer') def test_ticket_search(self): - t_list = self.c.ticket_search(Title='Welcome to OTRS!') + t_list = self.c.tc.TicketSearch(Title='Welcome to OTRS!') self.assertIsInstance(t_list, list) self.assertIn(1, t_list) @@ -206,7 +207,7 @@ def test_ticket_create(self): Body='bla', Charset='UTF8', MimeType='text/plain') - t_id, t_number = self.c.ticket_create(t, a) + t_id, t_number = self.c.tc.TicketCreate(t, a) self.assertIsInstance(t_id, int) self.assertIsInstance(t_number, int) self.assertTrue(len(str(t_number)) >= 12) @@ -223,11 +224,11 @@ def test_ticket_update_attrs_by_id(self): Body='bla', Charset='UTF8', MimeType='text/plain') - t_id, t_number = self.c.ticket_create(t, a) + t_id, t_number = self.c.tc.TicketCreate(t, a) t = Ticket(Title='Foubar') - upd_tid, upd_tnumber = self.c.ticket_update(ticket_id=t_id, - ticket=t) + upd_tid, upd_tnumber = self.c.tc.TicketUpdate(ticket_id=t_id, + ticket=t) self.assertIsInstance(upd_tid, int) self.assertIsInstance(upd_tnumber, int) self.assertTrue(len(str(upd_tnumber)) >= 12) @@ -235,7 +236,7 @@ def test_ticket_update_attrs_by_id(self): self.assertEqual(upd_tid, t_id) self.assertEqual(upd_tnumber, t_number) - upd_t = self.c.ticket_get(t_id) + upd_t = self.c.tc.TicketGet(t_id) self.assertEqual(upd_t.Title, 'Foubar') self.assertEqual(upd_t.Queue, 'Postmaster') @@ -250,11 +251,11 @@ def test_ticket_update_attrs_by_number(self): Body='bla', Charset='UTF8', MimeType='text/plain') - t_id, t_number = self.c.ticket_create(t, a) + t_id, t_number = self.c.tc.TicketCreate(t, a) t = Ticket(Title='Foubar') - upd_tid, upd_tnumber = self.c.ticket_update(ticket_number=t_number, - ticket=t) + upd_tid, upd_tnumber = self.c.tc.TicketUpdate(ticket_number=t_number, + ticket=t) self.assertIsInstance(upd_tid, int) self.assertIsInstance(upd_tnumber, int) self.assertTrue(len(str(upd_tnumber)) >= 12) @@ -262,7 +263,7 @@ def test_ticket_update_attrs_by_number(self): self.assertEqual(upd_tid, t_id) self.assertEqual(upd_tnumber, t_number) - upd_t = self.c.ticket_get(t_id) + upd_t = self.c.tc.TicketGet(t_id) self.assertEqual(upd_t.Title, 'Foubar') self.assertEqual(upd_t.Queue, 'Postmaster') @@ -277,7 +278,7 @@ def test_ticket_update_new_article(self): Body='bla', Charset='UTF8', MimeType='text/plain') - t_id, t_number = self.c.ticket_create(t, a) + t_id, t_number = self.c.tc.TicketCreate(t, a) a2 = Article(Subject='UnitTest2', Body='bla', @@ -289,10 +290,10 @@ def test_ticket_update_new_article(self): Charset='UTF8', MimeType='text/plain') - self.c.ticket_update(t_id, article=a2) - self.c.ticket_update(t_id, article=a3) + self.c.tc.TicketUpdate(t_id, article=a2) + self.c.tc.TicketUpdate(t_id, article=a3) - t_upd = self.c.ticket_get(t_id, get_articles=True) + t_upd = self.c.tc.TicketGet(t_id, get_articles=True) arts_upd = t_upd.articles() self.assertIsInstance(arts_upd, list) self.assertEqual(len(arts_upd), 3)