diff --git a/examples.py b/examples.py new file mode 100644 index 0000000..53abc89 --- /dev/null +++ b/examples.py @@ -0,0 +1,48 @@ +from __future__ import print_function, unicode_literals +from sugarcrm import Task + +# This is the URL for the v4 REST API in your SugarCRM server. +# url = '' +# username = '' +# password = '' + +# This way you log-in to your SugarCRM instance. +# conn = Sugarcrm(url, username, password) +# TaskModel = Task(conn) + + +# Examples for Django +# You need to put connection settings to the Django settings file first. +# SUGAR_CRM_URL = +# SUGAR_CRM_USERNAME = +# SUGAR_CRM_PASSWORD = + +# creation of new Tasks +new_task = Task(**{'name': 'test'}) +new_task.save() + +# change field value +TaskModel = Task() +task = TaskModel.objects.get(pk=new_task.id) +old_name = task.name +print('before change', task.name) +task['name'] = 'New Name' +print('before save', task.name) +task.save() + +# revert field value +query = TaskModel.objects.filter(id=new_task.id).only('id', 'name', 'date_start').order_by('date_start') +task = query.first() +print('after save', task.name) +task['name'] = old_name +task.save() +query = TaskModel.objects.filter(id=new_task.id).only('id', 'name', 'date_start').order_by('date_start') +task = query.first() +print('after revert', task.name) +task.delete() + +query = TaskModel.objects.filter(name=new_task.name).order_by('date_start') +task = query.first() +print('check after delete', task) + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6ae7ee3 --- /dev/null +++ b/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup, find_packages + +NAME = 'django-sugar-crm' +VERSION = '0.0.1' +PACKAGES = find_packages() +AUTHOR = 'an42rus' +URL = f'https://github.com/{AUTHOR}/{NAME}' + +setup( + name=NAME, + version=VERSION, + packages=PACKAGES, + author=AUTHOR, + url=URL, + description='SugarCRM Python library with object api similar to Django ORM.' +) diff --git a/sugarcrm/__init__.py b/sugarcrm/__init__.py new file mode 100644 index 0000000..d5092e7 --- /dev/null +++ b/sugarcrm/__init__.py @@ -0,0 +1,5 @@ +from .sugarcrm import * +from .sugarentry import * +from .rest_framework import * + +__version__ = "0.0.1" diff --git a/sugarcrm/rest_framework/__init__.py b/sugarcrm/rest_framework/__init__.py new file mode 100644 index 0000000..ca71f91 --- /dev/null +++ b/sugarcrm/rest_framework/__init__.py @@ -0,0 +1,2 @@ +from .helpers import * +from .filterset import * diff --git a/sugarcrm/rest_framework/filterset.py b/sugarcrm/rest_framework/filterset.py new file mode 100644 index 0000000..0aa62c2 --- /dev/null +++ b/sugarcrm/rest_framework/filterset.py @@ -0,0 +1,8 @@ +from django_filters.rest_framework import filterset + + +class FilterSet(filterset.FilterSet): + def filter_queryset(self, queryset): + for name, value in self.form.cleaned_data.items(): + queryset = self.filters[name].filter(queryset, value) + return queryset \ No newline at end of file diff --git a/sugarcrm/rest_framework/helpers.py b/sugarcrm/rest_framework/helpers.py new file mode 100644 index 0000000..67c6725 --- /dev/null +++ b/sugarcrm/rest_framework/helpers.py @@ -0,0 +1,21 @@ +class Field: + """ + Class needs to support DRF filtering system + """ + def __init__(self, name): + self.name = name + self.verbose_name = self.name.replace('_', ' ') + + +class Meta: + """ + Class need to support DRF views + """ + def __init__(self, object_name): + self.object_name = object_name + + def get_field(self, name): + """ + method needs to support DRF filtering system + """ + return Field(name) diff --git a/sugarcrm/settings.py b/sugarcrm/settings.py new file mode 100644 index 0000000..989e214 --- /dev/null +++ b/sugarcrm/settings.py @@ -0,0 +1,8 @@ +try: + from django.conf import settings +except: + settings = None + +API_URL = getattr(settings, 'SUGAR_CRM_URL', '') +USERNAME = getattr(settings, 'SUGAR_CRM_USERNAME', '') +PASSWORD = getattr(settings, 'SUGAR_CRM_PASSWORD', '') diff --git a/sugarcrm/sugarcrm.py b/sugarcrm/sugarcrm.py new file mode 100644 index 0000000..debd7c7 --- /dev/null +++ b/sugarcrm/sugarcrm.py @@ -0,0 +1,178 @@ +from six.moves import urllib +import hashlib +import json + +from .sugarerror import SugarError, SugarUnhandledException, is_error +from .settings import API_URL, USERNAME, PASSWORD + + +class Sugarcrm: + """Sugarcrm main interface class. + + This class is what is used to connect to and interact with the SugarCRM + server. + """ + + def __init__(self, url, username, password, is_ldap_member=False): + """Constructor for Sugarcrm connection. + + Keyword arguments: + url -- string URL of the sugarcrm REST API + username -- username to allow login upon construction + password -- password to allow login upon construction + """ + + # String which holds the session id of the connection, required at + # every call after 'login'. + self._session = "" + + # url which is is called every time a request is made. + self._url = url + + self._username = username + self._password = password + self._isldap = is_ldap_member + + # Attempt to login. + self._login() + + # Dynamically add the API methods to the object. + for method in ['get_user_id', 'get_user_team_id', + 'get_available_modules', 'get_module_fields', + 'get_entries_count', 'get_entry', 'get_entries', + 'get_entry_list', 'set_entry', 'set_entries', + 'set_relationship', 'set_relationships', + 'get_relationships', 'get_server_info', + 'set_note_attachment', 'get_note_attachment', + 'set_document_revision', 'get_document_revision', + 'search_by_module', 'get_report_entries', 'logout']: + # Use this to be able to evaluate "method". + def gen(method_name): + def f(*args): + try: + result = self._sendRequest(method_name, + [self._session] + list(args)) + except SugarError as error: + if error.is_invalid_session: + # Try to recover if session ID was lost + self._login() + result = self._sendRequest(method_name, + [self._session] + list(args)) + elif error.is_missing_module: + return None + elif error.is_null_response: + return None + elif error.is_invalid_request: + print(method_name, args) + else: + raise SugarUnhandledException('%d, %s - %s' % + (error.number, + error.name, + error.description)) + + return result + + f.__name__ = method_name + return f + + self.__dict__[method] = gen(method) + + # Add modules containers + self.modules = {} + self.rst_modules = dict((m['module_key'], m) + for m in self.get_available_modules()['modules']) + + def __getitem__(self, key): + if key not in self.rst_modules: + raise KeyError("Invalid Key '%s'" % key) + if key in self.rst_modules and key not in self.modules: + from .sugarentry import SugarEntry + self.modules[key] = SugarEntry(self, key) + return self.modules[key] + + def _sendRequest(self, method, data): + """Sends an API request to the server, returns a dictionary with the + server's response. + + It should not need to be called explicitly by the user, but rather by + the other functions. + + Keyword arguments: + method -- name of the method being called. + data -- parameters to the function being called, should be in a list + sorted by order of items + """ + data = json.dumps(data) + args = {'method': method, 'input_type': 'json', + 'response_type': 'json', 'rest_data': data} + params = urllib.parse.urlencode(args).encode('utf-8') + response = urllib.request.urlopen(self._url, params) + response = response.read().strip() + if not response: + raise SugarError({'name': 'Empty Result', + 'description': 'No data from SugarCRM.', + 'number': 0}) + try: + result = json.loads(response.decode('utf-8')) + except: + raise Exception(response.decode('utf-8')) + if is_error(result): + raise SugarError(result) + return result + + def _login(self): + """ + Establish connection to the server. + """ + + args = {'user_auth': {'user_name': self._username, + 'password': self.password}} + + x = self._sendRequest('login', args) + try: + self._session = x['id'] + except KeyError: + raise SugarUnhandledException + + def relate(self, main, *secondary, **kwargs): + """ + Relate two or more SugarEntry objects. + + Supported Keywords: + relateby -> iterable of relationship names. Should match the + length of *secondary. Defaults to secondary + module table names (appropriate for most + predefined relationships). + """ + + relateby = kwargs.pop('relateby', [s._table for s in secondary]) + args = [[main.module_name] * len(secondary), + [main['id']] * len(secondary), + relateby, + [[s['id']] for s in secondary]] + # Required for Sugar Bug 32064. + if main.module_name == 'ProductBundles': + args.append([[{'name': 'product_index', + 'value': '%d' % (i + 1)}] for i in range(len(secondary))]) + return self.set_relationships(*args) + + @property + def password(self): + """ + Returns an appropriately encoded password for this connection. + - md5 hash for standard login. + - plain text for ldap users + """ + if self._isldap: + return self._password + encode = hashlib.md5(self._password.encode('utf-8')) + result = encode.hexdigest() + return result + + +def get_connection(url=API_URL, username=USERNAME, password=PASSWORD): + if url and username and password: + return Sugarcrm(url, username, password) + raise SugarError({'name': 'Empty connection settings', + 'description': 'Empty connection settings', + 'number': 10}) diff --git a/sugarcrm/sugarentry.py b/sugarcrm/sugarentry.py new file mode 100644 index 0000000..d55679d --- /dev/null +++ b/sugarcrm/sugarentry.py @@ -0,0 +1,388 @@ +from __future__ import print_function + +import logging + +import six +from six.moves.html_parser import HTMLParser +from collections import defaultdict +from itertools import count + +from .sugarcrm import get_connection +from .sugarquerylist import QueryList +from .sugarerror import ObjectDoesNotExist, MultipleObjectsReturned + +from .rest_framework import Meta + + +HTMLP = HTMLParser() + +log = logging.getLogger(__name__) + + +class SugarEntry: + """Define an entry of a SugarCRM module.""" + _hashes = defaultdict(count(1).next if hasattr(count(1), 'next') else count(1).__next__) + + def __init__(self, connection=None, module_name=None, **initial_values): + """Represents a new or an existing entry. + + Keyword arguments: + connection -- Sugarcrm object to connect to a server + name -- name of SugarCRM module that this class will represent + initial_values -- initial field values + """ + + if module_name: + self.module_name = module_name + + self._meta = Meta(self.module_name) + + if connection: + self._connection = connection + else: + self._connection = get_connection() + + # Get the module fields through SugarCRM API. + result = self._connection.get_module_fields(self.module_name) + if result is None: + return + + self._available_fields = result['module_fields'] + + # In order to ensure that queries target the correct tables. + # Necessary to replace a call to self.module_name.lower() which + # was resulting in broken modules (ProductTemplates, etc). + self._table = result['table_name'] + # If there aren't relationships the result here is an empty list + # which has no copy method. Fixing to provide an empty default. + self._relationships = (result['link_fields'] or {}).copy() + + # Keep a mapping 'field_name' => value for every valid field retrieved. + self._fields = {} + self._dirty_fields = [] + + # Allow initial fields in constructor. + if initial_values is not None: + for key, value in initial_values.items(): + setattr(self, key, value) + # self._fields.update(initial_values) + + # Make sure that the 'id' field is always defined. + if 'id' not in list(self._fields.keys()): + self._fields['id'] = '' + + def __new__(cls, *args, **kwargs): + new_class = super().__new__(cls) + setattr(new_class, 'DoesNotExist', ObjectDoesNotExist) + setattr(new_class, 'MultipleObjectsReturned', MultipleObjectsReturned) + return new_class + + def __hash__(self): + return self._hashes['%s-%s' % (self.module_name, self['id'])] + + def __unicode__(self): + return "" % \ + (self.module_name.rstrip('s'), self['name']) + + def __str__(self): + return f'<{self.module_name} {self["id"]}>' + + def __contains__(self, key): + return key in self._available_fields + + def _retrieve(self, fieldlist, force=False): + qstring = "%s.id = '%s'" % (self._table, self['id']) + if not force: + fieldlist = set(fieldlist) - set(self._fields.keys()) + if not fieldlist: + return + res = self._connection.get_entry_list(self.module_name, + qstring, '', 0, + list(fieldlist), 1, 0) + if not res['entry_list'] or not res['entry_list'][0]['name_value_list']: + for field in fieldlist: + self[field] = '' + return + for prop, obj in list(res['entry_list'][0]['name_value_list'].items()): + if obj['value']: + self[prop] = HTMLP.unescape(obj['value']) + else: + self[prop] = '' + + def __getitem__(self, field_name): + """Return the value of the field 'field_name' of this SugarEntry. + + Keyword arguments: + field_name -- name of the field to be retrieved. Supports a tuple + of fields, in which case the return is a tuple. + """ + + if isinstance(field_name, tuple): + self._retrieve(field_name) + return tuple(self[n] for n in field_name) + + if field_name not in self._available_fields: + raise AttributeError("Invalid field '%s'" % field_name) + + if field_name not in self._fields: + self._retrieve([field_name]) + return self._fields[field_name] + + def __setattr__(self, key, value): + if hasattr(self, '_available_fields') and key in self._available_fields: + self.__dict__[key] = value + self._fields[key] = value + if key not in self._dirty_fields: + self._dirty_fields.append(key) + else: + super(SugarEntry, self).__setattr__(key, value) + + def __setitem__(self, field_name, value): + """Set the value of a field of this SugarEntry. + + Keyword arguments: + field_name -- name of the field to be updated + value -- new value for the field + """ + + if field_name in self._available_fields: + self.__dict__[field_name] = value + self._fields[field_name] = value + if field_name not in self._dirty_fields: + self._dirty_fields.append(field_name) + else: + raise AttributeError("Invalid field '%s'" % field_name) + + def _search(self, query_str, order_by='', offset='', limit='', fields=None, links_to_names=None): + """ + Return a dictionary of records as well as pertinent query + statistics. + + + Keyword arguments: + query_str -- SQL query to be passed to the API + offset -- Record offset to start from + limit -- Maximum number of results to return + fields -- If set, return only the specified fields + links_to_fields -- if set, retrieve related entries from link with fields specified. + query -- The actual query class instance. + """ + + if fields is None: + fields = list(self._available_fields.keys()) + if links_to_names is None: + links_to_names = [] + + result = {} + + entry_list = [] + resp_data = self._connection.get_entry_list(self.module_name, + query_str, order_by, offset, fields, + links_to_names, limit, 0) + if resp_data['total_count']: + try: + result['total'] = int(resp_data['total_count'], 10) + except TypeError as e: + log.error(resp_data) + log.exception(e) + else: + result['total'] = 0 + + for idx, record in enumerate(resp_data['entry_list']): + entry = SugarEntry(self._connection, self.module_name) + for key, obj in list(record['name_value_list'].items()): + val = obj['value'] + setattr(entry, key, val) + entry.related_beans = defaultdict(list) + try: + linked = resp_data['relationship_list'][idx] + for block in linked['link_list']: + entry.related_beans[block['name']].extend(block['records']) + except: + pass + entry_list.append(entry) + result['entries'] = entry_list + return result + + def fields(self): + return self._fields + + def save(self): + """Save this entry in the SugarCRM server. + + If the 'id' field is blank, it creates a new entry and sets the + 'id' value. + """ + is_new_object = True + + # If 'id' wasn't blank, it's added to the list of dirty fields; this + # way the entry will be updated in the SugarCRM connection. + if self['id'] != '': + self._dirty_fields.append('id') + is_new_object = False + + # nvl is the name_value_list, which has the list of attributes. + nvl = [] + for field in set(self._dirty_fields): + # Define an individual name_value record. + nv = dict(name=field, value=self[field]) + nvl.append(nv) + + # Use the API's set_entry to update the entry in SugarCRM. + result = self._connection.set_entry(self.module_name, nvl) + try: + setattr(self, 'id', result['id']) + except: + print(result) + + # fetch all fields for new object + if is_new_object: + obj = self.objects.get(id=self.id) + + for field, value in obj.fields().items(): + setattr(self, field, value) + self._dirty_fields = [] + + def delete(self): + self.deleted = 1 + self.save() + + def relate(self, *related, **kwargs): + """ + Relate this SugarEntry with other Sugar Entries. + + Positional Arguments: + related -- Secondary SugarEntry Object(s) to relate to this entry. + Keyword arguments: + relateby -> iterable of relationship names. Should match the + length of *secondary. Defaults to secondary + module table names (appropriate for most + predefined relationships). + """ + + self._connection.relate(self, *related, **kwargs) + + def get_related(self, module, fields=None, relateby=None, links_to_fields=None): + """Return the related entries in another module. + + Keyword arguments: + module -- related SugarModule object + relateby -- custom relationship name (defaults to module.lower()) + links_to_fields -- Allows retrieval of related fields from addtional related modules for retrieved records. + """ + + if fields is None: + fields = ['id'] + if links_to_fields is None: + links_to_fields = [] + connection = self._connection + # Accomodate retrieval of modules by name. + if isinstance(module, six.string_types): + module = connection[module] + result = connection.get_relationships(self.module_name, + self['id'], + relateby or module.module_name.lower(), + '', # Where clause placeholder. + fields, + links_to_fields) + entries = [] + for idx, elem in enumerate(result['entry_list']): + entry = SugarEntry(module) + for name, field in list(elem['name_value_list'].items()): + val = field['value'] + entry._fields[name] = HTMLP.unescape(val) if isinstance(val, basestring) else val + entry.related_beans = defaultdict(list) + # try: + linked = result['relationship_list'][idx] + for relmod in linked: + for record in relmod['records']: + relentry = {} + for fname, fmap in record.items(): + rfield = fmap['value'] + relentry[fname] = HTMLP.unescape(rfield) if isinstance(rfield, six.string_types) else val + entry.related_beans[relmod['name']].append(relentry) + # except: + # pass + + entries.append(entry) + + return entries + + @property + def objects(self): + """ + Return a QueryList object for this SugarModule. + + Initially, it describes all the objects in the module. One can find + specific objects by calling 'filter' and 'exclude' on the returned + object. + """ + return QueryList(self, fields=None, links_to_names=None) + + +class Call(SugarEntry): + module_name = "Calls" + + +class Campaign(SugarEntry): + module_name = "Campaigns" + + +class Contact(SugarEntry): + module_name = "Contacts" + + +class Document(SugarEntry): + module_name = "Documents" + + +class Email(SugarEntry): + module_name = "Emails" + + +class Lead(SugarEntry): + module_name = "Leads" + + +class Module(SugarEntry): + module_name = "Modules" + + +class Note(SugarEntry): + module_name = "Notes" + + +class Opportunity(SugarEntry): + module_name = "Opportunities" + + +class Product(SugarEntry): + module_name = "Products" + + +class Prospect(SugarEntry): + module_name = "Prospects" + + +class ProspectList(SugarEntry): + module_name = "ProspectLists" + + +class Quote(SugarEntry): + module_name = "Quotes" + + +class Report(SugarEntry): + module_name = "Reports" + + +class User(SugarEntry): + module_name = "Users" + + +class Task(SugarEntry): + module_name = "Tasks" + + +class Account(SugarEntry): + module_name = "Accounts" diff --git a/sugarcrm/sugarerror.py b/sugarcrm/sugarerror.py new file mode 100644 index 0000000..1f08069 --- /dev/null +++ b/sugarcrm/sugarerror.py @@ -0,0 +1,55 @@ + +class SugarError(Exception): + def __init__(self, data): + self.name = data['name'] + self.description = data['description'] + self.number = data['number'] + + @property + def is_invalid_session(self): + return self.number == 11 + + @property + def is_invalid_login(self): + return self.number == 10 + + @property + def is_missing_module(self): + return self.number == 20 + + @property + def is_null_response(self): + return self.number == 0 + + @property + def is_invalid_request(self): + return self.number == 1001 + + +class SugarUnhandledException(Exception): + pass + + +def is_error(data): + try: + if data['name'] in ('Module Does Not Exist',): + return True + return data["name"] is not None and data["description"] is not None + except KeyError: + return False + + +class ObjectDoesNotExist(Exception): + """The requested object does not exist""" + silent_variable_failure = True + + +class MultipleObjectsReturned(Exception): + """The query returned multiple objects when only one was expected.""" + pass + +# override exceptions to django exceptions +try: + from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +except: + pass \ No newline at end of file diff --git a/sugarcrm/sugarquerylist.py b/sugarcrm/sugarquerylist.py new file mode 100644 index 0000000..045caf7 --- /dev/null +++ b/sugarcrm/sugarquerylist.py @@ -0,0 +1,371 @@ +from __future__ import unicode_literals + +import copy +import logging + +from six.moves import html_parser + +HTMLP = html_parser.HTMLParser() + +log = logging.getLogger(__name__) + + +class QueryList: + """Query a SugarCRM module for specific entries.""" + + def __init__(self, entry, query='', order_by='', limit='', offset='', fields=None, links_to_names=None): + """Constructor for QueryList. + + Keyword arguments: + entry -- SugarEntry object to query + query -- SQL query to be passed to the API + """ + + self.model = entry + self._query = query + self._order_by = order_by + self._result_cache = None + self.low_mark, self.high_mark = 0, None # Used for offset/limit + self._limit = limit + self._offset = offset + self._total = -1 + self._sent = 0 + self._fields = fields + self._links_to_names = links_to_names + + def __deepcopy__(self, memo): + """Don't populate the QuerySet's cache.""" + obj = self.__class__() + for k, v in self.__dict__.items(): + if k == '_result_cache': + obj.__dict__[k] = None + else: + obj.__dict__[k] = copy.deepcopy(v, memo) + return obj + + def __repr__(self): + return '<%s %s>' % (self.__class__.__name__, self.model.module_name) + + def _fetch_all(self): + # run query + if self._result_cache is None: + result = self.model._search(self._query, self._order_by, self._offset, self._limit, self._fields, + self._links_to_names) + self._result_cache = result.get('entries', []) + + def __len__(self): + if self._result_cache is None: + self._fetch_all() + return len(self._result_cache) + + def __iter__(self): + self._fetch_all() + return iter(self._result_cache) + + def __bool__(self): + self._fetch_all() + return bool(self._result_cache) + + def __getitem__(self, k): + self.clear_limits() + """Retrieve an item or slice from the set of results.""" + if not isinstance(k, (int, slice)): + raise TypeError( + 'QuerySet indices must be integers or slices, not %s.' + % type(k).__name__ + ) + assert ((not isinstance(k, slice) and (k >= 0)) or + (isinstance(k, slice) and (k.start is None or k.start >= 0) and + (k.stop is None or k.stop >= 0))), \ + "Negative indexing is not supported." + + if self._result_cache is not None: + return self._result_cache[k] + + if isinstance(k, slice): + qs = self._chain() + if k.start is not None: + start = int(k.start) + else: + start = None + if k.stop is not None: + stop = int(k.stop) + else: + stop = None + + qs.set_limits(start, stop) + qs._fetch_all() + return qs._result_cache[::k.step] if k.step else qs._result_cache + + qs = self._chain() + qs.set_limits(k, k + 1) + qs._fetch_all() + return qs._result_cache[0] + + def _chain(self, **kwargs): + """ + Return a copy of the current QuerySet that's ready for another + operation. + """ + obj = self._clone() + obj.__dict__.update(kwargs) + return obj + + def _clone(self): + """ + Return a copy of the current QuerySet. A lightweight alternative + to deepcopy(). + """ + return QueryList(self.model, + query=self._query, + order_by=self._order_by, + limit=self._limit, + offset=self._offset, + fields=self._fields, + links_to_names=self._links_to_names) + + def set_limits(self, low=None, high=None): + """ + Adjust the limits on the rows retrieved. Use low/high to set these, + as it makes it more Pythonic to read and write. When the SQL query is + created, convert them to the appropriate offset and limit values. + + Apply any limits passed in here to the existing constraints. Add low + to the current low value and clamp both to any existing high value. + """ + if high is not None: + if self.high_mark is not None: + self.high_mark = min(self.high_mark, self.low_mark + high) + else: + self.high_mark = self.low_mark + high + if low is not None: + if self.high_mark is not None: + self.low_mark = min(self.high_mark, self.low_mark + low) + else: + self.low_mark = self.low_mark + low + + if self.low_mark == self.high_mark: + # clear limit offset + self.clear_limits() + + if self.low_mark != self.high_mark: + self._limit, self._offset = self._get_limit_offset_params(self.low_mark, self.high_mark) + + def clear_limits(self): + self._limit, self._offset = None, 0 + + @staticmethod + def _get_limit_offset_params(low_mark, high_mark): + offset = low_mark or 0 + if high_mark is not None: + return (high_mark - offset), offset + return None, offset + + def _build_query(self, **query): + """Build the API query string. + """ + + available_fields = list(self.model._available_fields.keys()) + + q_str = '' + for key, val in list(query.items()): + # Get the field and the operator from the query + key_field, key_sep, key_oper = key.partition('__') + + if key_field == 'pk' and 'id' not in query: + key_field = 'id' + + if key_field in available_fields: + if q_str != '': + q_str += ' AND ' + + if_cstm = '' + if key_field.endswith('_c'): + if_cstm = '_cstm' + + field = self.model._table + if_cstm + '.' + key_field + + if key_oper in ('exact', 'eq') or (not key_oper and not key_sep): + q_str += '%s = "%s"' % (field, val) + elif key_oper == 'contains': + q_str += '%s LIKE "%%%s%%"' % (field, val) + elif key_oper == 'startswith': + q_str += '%s LIKE "%s%%"' % (field, val) + elif key_oper == 'in': + q_str += '%s IN (' % field + for elem in val: + q_str += "'%s'," % elem + q_str = q_str.rstrip(',') + q_str += ')' + elif key_oper == 'gt': + q_str += '%s > "%s"' % (field, val) + elif key_oper == 'gte': + q_str += '%s >= "%s"' % (field, val) + elif key_oper == 'lt': + q_str += '%s < "%s"' % (field, val) + elif key_oper == 'lte': + q_str += '%s <= "%s"' % (field, val) + else: + raise LookupError('Unsupported operator') + + return q_str + + def get(self, **query): + query = self._build_query(**query) + + qs = QueryList(self.model, + query, + order_by='', + limit='', + offset='', + fields=self._fields, + links_to_names=self._links_to_names) + num = len(qs) + if num == 1: + return qs.first() + if not num: + raise self.model.DoesNotExist( + "%s matching query does not exist." % + self.model.module_name + ) + raise self.model.MultipleObjectsReturned( + 'get() returned more than one %s -- it returned %s!' % ( + self.model.module_name, + num, + ) + ) + + def filter(self, **query): + """Filter this QueryList, returning a new QueryList. + + Keyword arguments: + query -- kwargs dictionary where the filters are specified: + The keys should be some of the entry's field names, suffixed by + '__' and one of the following operators: 'exact', 'contains', 'in', + 'gt', 'gte', 'lt' or 'lte'. When the operator is 'in', the + corresponding value MUST be a list. + """ + + if self._query != '': + query = '(%s) AND (%s)' % (self._query, self._build_query(**query)) + else: + query = self._build_query(**query) + + return QueryList(self.model, + query, + order_by=self._order_by, + limit=self._limit, + offset=self._offset, + fields=self._fields, + links_to_names=self._links_to_names) + + def all(self): + return QueryList(self.model, + self._query, + order_by=self._order_by, + limit=self._limit, + offset=self._offset, + fields=self._fields, + links_to_names=self._links_to_names) + + def exclude(self, **query): + """Filter this QueryList, returning a new QueryList, as in filter(), + but excluding the entries that match the query. + """ + + if self._query != '': + query = '(%s) AND NOT (%s)' % (self._query, self._build_query(**query)) + else: + query = 'NOT (%s)' % self._build_query(**query) + + return QueryList(self.model, + query, + order_by=self._order_by, + fields=self._fields, + limit=self._limit, + offset=self._offset, + links_to_names=self._links_to_names) + + def remove_invalid_fields(self, fields): + valid_fields = [] + available_fields = list(self.model._available_fields.keys()) + for field in fields: + if field in available_fields: + valid_fields.append(field) + return valid_fields + + def _get_ordering_field(self, value): + desc = False + field_name = value + + if field_name.startswith('-'): + desc = True + field_name = field_name[1:] + + if field_name == 'pk': + field_name = 'id' + + valid_fields = self.remove_invalid_fields([field_name, ]) + if field_name in valid_fields: + return field_name, desc + + return None, None + + def order_by(self, value): + field_name, desc = self._get_ordering_field(value) + order_by = self._order_by + + if field_name is not None: + order_by = field_name + if desc: + order_by = f'{order_by} desc' + + return QueryList(self.model, + self._query, + order_by=order_by, + fields=self._fields, + limit=self._limit, + offset=self._offset, + links_to_names=self._links_to_names) + + def count(self): + if self._total == -1: + result = self.model._connection.get_entries_count(self.model.module_name, self._query, 0) + + self._total = int(result['result_count'], 10) + return self._total + + def first(self): + if self._result_cache is None: + self._fetch_all() + for obj in self._result_cache[:1]: + return obj + + def only(self, *_fields): + fields = self._fields + valid_fields = self.remove_invalid_fields(_fields) + + if valid_fields: + fields = valid_fields + + return QueryList(self.model, + self._query, + order_by=self._order_by, + fields=fields, + limit=self._limit, + offset=self._offset, + links_to_names=self._links_to_names) + + def links_to_names(self, *_links_to_names): + links_to_names = self._links_to_names + + if _links_to_names: + links_to_names = _links_to_names + + return QueryList(self.model, + self._query, + order_by=self._order_by, + fields=self._fields, + limit=self._limit, + offset=self._offset, + links_to_names=links_to_names)