diff --git a/actionkit/api/__init__.py b/actionkit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/actionkit/api/base.py b/actionkit/api/base.py new file mode 100644 index 0000000..ab31c50 --- /dev/null +++ b/actionkit/api/base.py @@ -0,0 +1,21 @@ +import requests + +class ActionKitAPI(object): + + def __init__(self, settings): + self.settings = settings + self.client = requests.Session() + self.client.auth = (settings.AK_USER, settings.AK_PASSWORD) + self.client.headers.update({'content-type': 'application/json', + 'accepts': 'application/json'}) + + self.base_url = settings.AK_BASEURL + + + def _http_return(self, res): + """ + Return based on http response success + """ + #if 200 >= res.status_code < 300: + return res + diff --git a/actionkit/api/user.py b/actionkit/api/user.py new file mode 100644 index 0000000..680a9fb --- /dev/null +++ b/actionkit/api/user.py @@ -0,0 +1,127 @@ +import json +import re +import requests + +from actionkit.api import base + + +class AKUserAPI(base.ActionKitAPI): + + def set_usertag(self, user_id, name_or_dict, value=None): + """ + send a dict, or a single name, value pair + """ + if not hasattr(name_or_dict, 'get') and value: + name_or_dict = { name_or_dict: value} + + res = self.client.put( + #the '/' at the end is IMPORTANT! + '%s/rest/v1/user/%s/' % (self.base_url, user_id), + data=json.dumps({ 'fields': name_or_dict })) + return self._http_return(res) + + def set_eventfield(self, event_id, name, value, eventfield_id=None): + """ + send a dict, or a single name, value pair + """ + d = { 'name': name, + 'value': value, + 'event': '/rest/v1/event/%s/' % event_id} + method = 'post' + #the '/' at the end is IMPORTANT! + url = '%s/rest/v1/eventfield/' % self.base_url + if eventfield_id: + method = 'put' + url = url + ('%s/' % eventfield_id) + res = getattr(self.client, method)(url, data=json.dumps(d)) + #import pdb; pdb.set_trace() + return self._http_return(res) + + def add_allowed_usertag(self, userfield_name): + res = self.client.post( + '%s/rest/v1/alloweduserfield/' % self.base_url, + json={'name': userfield_name}) + return self._http_return(res) + + def get_user(self, user_id): + if getattr(self.settings, 'AK_TEST', False): + return TEST_DATA.get(user_id) + res = self.client.get( + #the '/' at the end is IMPORTANT! + '%s/rest/v1/user/%s/' % (self.base_url, user_id)) + return {'res': res, + 'user': res.json() if res.status_code == 200 else None} + + def get_phone(self, phone_id=None, url=None): + assert(phone_id or url) + if getattr(self.settings, 'AK_TEST', False): + return TEST_DATA.get(url) + if not url: + #the '/' at the end is IMPORTANT! + url = '/rest/v1/phone/%s/' % phone_id + res = self.client.get( + '%s%s' % (self.base_url, url)) + return {'res': res, + 'phone': res.json() if res.status_code == 200 else None} + + def create_user(self, user_dict): + if getattr(self.settings, 'AK_TEST', False): + return TEST_DATA.get(user_dict.get('email', 'create_user')) + res = self.client.post( + '%s/rest/v1/user/' % self.base_url, + json=user_dict) + rv = {'res': res} + if res.headers.get('Location'): + rv['id'] = re.findall(r'(\d+)/$', res.headers['Location'])[0] + return rv + + +TEST_DATA = { + 'create_user': { + 'res': None, + 'id': 123123, + }, + 'example@example.com': { + 'res': None, + 'id': 123123, + }, + '/rest/v1/phone/8675309/': { + 'res': None, + 'phone': { + "created_at": "2015-11-24T21:07:58", + "id": 8675309, + "normalized_phone": "5558675309", + "phone": "5558675309", + "resource_uri": "/rest/v1/phone/8675309/", + "source": "user", + "type": "home", + "updated_at": "2016-03-29T16:41:10", + "user": "/rest/v1/user/123123/" + } + }, + '123123': { #fake userid + 'res': None, + #some fields removed for brevity. + # add them back if you need them for testing + 'user': { + "address1": "123 Main St.", + "address2": "", + "city": "Cleveland", + "country": "United States", + "created_at": "2015-11-18T16:22:31", + "email": "example@example.com", + "fields": { }, + "first_name": "Roger", + "last_name": "AndMe", + "id": 123123, + "phones": [ + "/rest/v1/phone/8675309/" + ], + "postal": "44123", + "region": "OH", + "state": "OH", + "updated_at": "2016-07-11T18:19:26", + "zip": "44123", + } + } +} diff --git a/actionkit/models.py b/actionkit/models.py index 62fa749..f696c76 100644 --- a/actionkit/models.py +++ b/actionkit/models.py @@ -1,6 +1,47 @@ +import datetime +import re + from django.db import models +#for all the group by/having hacking +from django.db.models.sql.constants import INNER, LOUTER +from django.db.models.sql.datastructures import Join +from django.db.models.sql.where import ( + AND, OR, ExtraWhere, NothingNode, WhereNode, +) +from django.conf import settings +from django.utils import timezone from actionkit import ActionKitGeneralError +from actionkit.api.user import AKUserAPI + +class HavingGroupCondition(ExtraWhere): + contains_aggregate = True + + def get_group_by_cols(self): + #django looks for this method with aggregates. See models.Count code + return [] + + +class JoinField: + """We need this because of the extra restriction on the join + for add_userfield_to_queryset() and add_eventfield_to_queryset() + """ + def __init__(self, basis, customfield, cols=None): + self.basis = basis + self.customfield = customfield + #in the form of (('
', ''), ) + self.cols = cols + + def get_joining_columns(self): + if self.cols: + return self.cols + else: + return self.basis.get_joining_columns() + + def get_extra_restriction(self, where_class, alias, related_alias): + #alias is core_userfield and related_alias is core_user + return ExtraWhere(["{}.name = %s".format(alias)], (self.customfield,)) + class _akit_model(models.Model): @@ -1647,7 +1688,161 @@ class CoreUploadwarning(_akit_model): class Meta(_akit_model.Meta): db_table = u'core_uploadwarning' + +class CoreUserManager(models.Manager): + """ + This manager includes a bunch of methods that alter a queryset to do a + reverse join -- i.e. when we query CoreUser, but we want some data + joined to it where the *other* table (e.g. CoreUserField or CorePhone) + has a ForeignKey pointing to CoreUser. + + Django (AFAIK) doesn't have a bunch of facilities for that, so this is + fairly 'low-level' Django ORM hacking -- adding the joins manually. + + It's worth noting, if we 'just' wanted to get a specific query, then + Django *does* have a raw sql API. However: + 1. That's not compatible with the Django Admin which we are using usefully + 2. We'd still be met with difficulty in composing queries together -- + i.e. the whole point of using an ORM is making conditions composable + (adding conditions together/incrementally) + """ + + def action_counts(self, actiontype, min_count): + qs = super(CoreUserManager, self).get_queryset() + return CoreUserManager.action_counts_filter(qs, actiontype, min_count) + + @classmethod + def action_counts_filter(cls, qs, actiontype, min_count=1, since_days=None): + # the main reason we have to be this lowlevel is adding the 'HAVING' clause. + # otherwise, django's extra() or aggregate() would be fine + assert(issubclass(actiontype, CoreAction)) + + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + actionalias = qs.query.join(Join('core_action', qs.query.get_initial_alias(), 'core_action', INNER, + CoreUser._meta.fields_map['actions'], False)) + qs.query.join(Join(actiontype._meta.db_table, actionalias, 'acounts_atype', INNER, + CoreAction._meta.fields_map[actiontype._meta.model_name], + False)) + #group by everything except our aggregate annotation + qs.query.group_by = [x.name for x in CoreUser._meta.local_fields] + + qs.query.add_annotation(models.Count('actions__id'), 'action_count', is_summary=False) + xtrawhere = HavingGroupCondition(['count(DISTINCT {}.id, core_user.id) >= %s'.format(actionalias)], (min_count,)) + qs.query.where.add(xtrawhere, AND) + + if since_days: + since = timezone.now() - datetime.timedelta(days=since_days) + qs = qs.extra(where=['{}.created_at > %s'.format(actionalias)], params=[since]) + #print('sql query', qs.query.sql_with_params()) #what SQL will we run? + return qs + + + @classmethod + def actionfield_filter(cls, qs, actionfield_name, actionfield_value=None, pages=None, min_count=1, since_days=None): + # note this is tweaked from the method above action_counts_filter + + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + actionalias = qs.query.join(Join('core_action', qs.query.get_initial_alias(), 'core_action', INNER, + CoreUser._meta.fields_map['actions'], False)) + actionfield_alias = qs.query.join(Join(ActionField._meta.db_table, actionalias, 'avals_val', INNER, + Action._meta.fields_map['customfields'], + False)) + #group by everything except our aggregate annotation + # this is generically problematic, because if we need to group by other things, + # then this will fail + qs.query.group_by = [x.name for x in CoreUser._meta.local_fields] + + qs.query.add_annotation(models.Count('actions__value'), 'actionval_count', is_summary=False) + xtrawhere = HavingGroupCondition(['count(DISTINCT {}.value, core_user.id) >= %s'.format(actionfield_alias)], (min_count,)) + qs.query.where.add(xtrawhere, AND) + + where2 = ExtraWhere(["{af}.name = %s".format(af=actionfield_alias)],(actionfield_name,)) + qs.query.where.add(where2, AND) + if actionfield_value: + where3 = ExtraWhere(["{af}.value = %s".format(af=actionfield_alias)],(actionfield_value,)) + qs.query.where.add(where3, AND) + if pages: + qs = qs.extra(where=['{}.page_id IN %s'.format(actionalias)], params=[pages]) + + if since_days: + since = timezone.now() - datetime.timedelta(days=since_days) + qs = qs.extra(where=['{}.created_at > %s'.format(actionalias)], params=[since]) + #print('sql query', qs.query.sql_with_params()) #what SQL will we run? + return qs + + @classmethod + def userfield_filter(cls, qs, userfield_name, userfield_value=None, min_count=1, search=False): + # note this is tweaked from the method above action_counts_filter + # min_count is *mostly* useless, but i think it does actually store multiple user values occasionally, or always. + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + uf_alias = qs.query.join(Join('core_userfield', qs.query.get_initial_alias(), 'core_userfield', INNER, + CoreUser._meta.fields_map['customfields'], False)) + + #group by everything except our aggregate annotation + # this is generically problematic, because if we need to group by other things, + # then this will fail + qs.query.group_by = [x.name for x in CoreUser._meta.local_fields] + + qs.query.add_annotation(models.Count('customfields__name'), 'uf_count', is_summary=False) + xtrawhere = HavingGroupCondition(['count(DISTINCT {}.value, core_user.id) >= %s'.format(uf_alias)], (min_count,)) + qs.query.where.add(xtrawhere, AND) + + where2 = ExtraWhere(["{uf}.name = %s".format(uf=uf_alias)],(userfield_name,)) + qs.query.where.add(where2, AND) + if userfield_value: + op = '=' if not search else 'LIKE' + where3 = ExtraWhere(["{uf}.value {op} %s".format(uf=uf_alias, op=op)],(userfield_value,)) + qs.query.where.add(where3, AND) + + #print('sql query', qs.query.sql_with_params()) #what SQL will we run? + return qs + + @classmethod + def add_location_to_queryset(cls, qs): + """ + Joins core_location to the query + """ + qa = qs.query.join(Join('core_location', qs.query.get_initial_alias(), 'core_location', LOUTER, + CoreUser._meta.fields_map['location'], True)) + + qs = qs.extra(select={ + 'us_district': '%s.us_district'.format(qa=qa), + }) + return qs + + @classmethod + def add_phone_to_queryset(cls, qs): + """ + Adds the first phone number to the queryset (probably MySQL dependent on first-row no-fussing) + """ + phone_alias = qs.query.join(Join('core_phone', qs.query.get_initial_alias(), 'core_phone', LOUTER, + CoreUser._meta.fields_map['phones'], True)) + #group by everything except our aggregate annotation (bad general assumption) + qs.query.group_by = [x.name for x in CoreUser._meta.local_fields] + + qs = qs.extra(select={'first_phone': '%s.phone' % phone_alias}) + return qs + + @classmethod + def add_userfield_to_queryset(cls, qs, userfieldname): + """ + Adds the first phone number to the queryset (probably MySQL dependent on first-row no-fussing) + """ + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + uf_alias = qs.query.join(Join('core_userfield', qs.query.get_initial_alias(), 'core_userfield', LOUTER, + JoinField(CoreUser._meta.fields_map['customfields'], userfieldname), True)) + userattr = 'userfield_%s' % userfieldname + qs = qs.extra(select={userattr: '%s.value' % uf_alias}) + return qs + + @classmethod + def action_value_filter(cls, qs, fieldname, min_count=1, since_days=None): + return qs + + class CoreUser(_akit_model): + objects = CoreUserManager() + created_at = models.DateTimeField() updated_at = models.DateTimeField() email = models.CharField(max_length=765, unique=True) @@ -1671,7 +1866,6 @@ class CoreUser(_akit_model): lang = models.ForeignKey('CoreLanguage', null=True, blank=True) rand_id = models.IntegerField() - # Return Fields As A Dictionary def custom_fields(self): fields = {} @@ -1689,8 +1883,16 @@ def actions(self): def __str__(self): return u'%s %s' % (self.first_name, self.last_name) + def recent_phone(self): + #get's most recent phone and parses makes it readable + return getattr(self.phones.order_by('-id').first(), 'normalized_phone', None) + class Meta(_akit_model.Meta): db_table = 'core_user' + verbose_name_plural = 'Member Search' + permissions = ( + ("csvswap", "Use CSV Swap to get member data"), + ) class CoreUserfield(_akit_model): parent = models.ForeignKey('CoreUser', related_name='customfields') @@ -1703,6 +1905,16 @@ class Meta(_akit_model.Meta): def __str__(self): return self.value + def api_save(self, **kwargs): + class aksettings: + AK_USER = settings.AK_USER + AK_PASSWORD = settings.AK_PASSWORD + AK_BASEURL = settings.AK_BASEURL + DEBUG = False + + akapi = AKUserAPI(aksettings) + res = akapi.set_usertag(self.parent_id, {self.name: self.value}) + class CoreUsermailing(_akit_model): mailing = models.ForeignKey('CoreMailing') @@ -1800,8 +2012,57 @@ class EventsCampaign(_akit_model): class Meta(_akit_model.Meta): db_table = u'events_campaign' verbose_name_plural = 'Event Campaigns' + ordering = ['-id'] #so recent campaigns are shown first + + def __str__(self): + return '%s %s' % ( + self.title, + self.starts_at.strftime('%m/%d/%y') if self.starts_at else '') + + +class EventsEventManager(models.Manager): + + @classmethod + def add_eventfield_to_queryset(cls, qs, fieldname, filtervalue=None): + """ + Adds the first phone number to the queryset (probably MySQL dependent on first-row no-fussing) + """ + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + query_alias = qs.query.join(Join('events_eventfield', qs.query.get_initial_alias(), 'events_eventfield', LOUTER, + JoinField(EventsEvent._meta.fields_map['customfields'], fieldname), True)) + #group by everything except our aggregate annotation (bad general assumption) + qs.query.group_by = [x.name for x in EventsEvent._meta.local_fields] + + if filtervalue: + where = "{qa}.value = %s" + qs.query.where.add(ExtraWhere([where.format(qa=query_alias)], [filtervalue]), AND) + + fieldname_id = '%s_id' % fieldname + qs = qs.extra(select={ + fieldname: '%s.value' % query_alias, + fieldname_id: '%s.id' % query_alias, + }) + return qs + + @classmethod + def add_creator_userfield_to_queryset(cls, qs, userfieldname): + """ + Adds the first phone number to the queryset (probably MySQL dependent on first-row no-fussing) + """ + #args for join: table_name, parent_alias, table_alias, join_type, join_field, nullable + uf_alias = qs.query.join(Join('core_userfield', qs.query.get_initial_alias(), 'core_userfield', LOUTER, + JoinField(CoreUser._meta.fields_map['customfields'], userfieldname, + cols=(('creator_id', 'parent_id'),) + ), True)) + userattr = 'userfield_%s' % userfieldname + qs = qs.extra(select={userattr: '%s.value' % uf_alias}) + return qs + + class EventsEvent(_akit_model): + objects = EventsEventManager() + created_at = models.DateTimeField() updated_at = models.DateTimeField() address1 = models.CharField(max_length=765) @@ -1837,6 +2098,20 @@ class Meta(_akit_model.Meta): db_table = u'events_event' verbose_name_plural = 'Events' + def __str__(self): + return '%s (%s%s, %s)' % ( + self.title, + self.starts_at.strftime('%m/%d/%y ') if self.starts_at else '', + self.city, + self.state + ) + + def act_as_host_link(self): + base_url = getattr(settings, 'AK_BASEURL', False) + if base_url: + return '%s/event/%s/%s/host/' % (base_url, self.campaign.name, self.id) + + class EventsEventfield(_akit_model): parent = models.ForeignKey('EventsEvent', related_name='customfields') name = models.CharField(max_length=765)