diff --git a/README.md b/README.md
new file mode 100644
index 00000000000..9170a80cc75
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Posthog
+
+## Running locally
+1) Make sure you have python 3 installed `python3 --version`
+2) Make sure you have postgres installed `brew install postgres`
+3) Start postgres, run `brew services start postgresql`
+4) Create Database `createdb posthog`
+5) Navigate into the correct folder `cd posthog`
+6) Run `python3 -m venv env` (creates virtual environment in current direction called 'env')
+7) Run `source env/bin/activate` (activates virtual environment)
+8) Run `pip install -r requirements.txt`
+9) Run migrations `python manage.py migrate`
+10) Run `python manage.py createsuperuser`
+11) Create a username, email and password
+12) Run `python manage.py runserver`
+
+## Running tests
+`bin/tests`
+
+## Running frontend
+
+`bin/start-frontend`
+
+## Pulling production database locally
+
+`bin/pull_production_db`
+
+## Create a new branch
+If you are working on some changes, please create a new branch, submit it to github ask for approval and when it gets approved it should automatically ship to Heroku
+
+* Before writing anything run `git pull origin master`
+* Then create your branch `git checkout -b %your_branch_name%` call your branch something that represents what you're planning to do
+* When you're finished add your changes `git add .`
+* And commit with a message `git commit -m "%your feature description%" `
+* When pushing to github make sure you push your branch name and not master!!
+
+## Deployment to Heroku
+
+* `git push origin %branch_name%` (sends it to Github) - DO NOT use `git push heroku master`
+* Be very careful running migrations by testing if they work locally first (ie run makemigrations, migrate, runserver locally when you've made database changes)
+* James or Tim will approve your change, and will deploy it to master
diff --git a/frontend/editor/index.js b/frontend/editor/index.js
index 922fe8fe439..dcfca0af149 100644
--- a/frontend/editor/index.js
+++ b/frontend/editor/index.js
@@ -139,6 +139,7 @@ class SelectElement extends Component {
+ {this.state.step.id}:{this.state.step.isNew}
{this.state.step.href && {
- this.state.action.steps = this.state.action.steps.map((s) => (s.id == step.id || (step.isNew && s.isNew == step.isNew)) ? {...step, ...newStep} : s);
+ this.state.action.steps = this.state.action.steps.map((s) => ((step.id && s.id == step.id) || (step.isNew && s.isNew == step.isNew)) ? {...step, ...newStep} : s);
this.setState({action: this.state.action});
}} />
)}
diff --git a/posthog/api/action.py b/posthog/api/action.py
index 65954c820ef..7549c9c1246 100644
--- a/posthog/api/action.py
+++ b/posthog/api/action.py
@@ -1,7 +1,9 @@
-from posthog.models import Event, Team, Action, ActionStep
+from posthog.models import Event, Team, Action, ActionStep, Element
from rest_framework import request, serializers, viewsets # type: ignore
from rest_framework.response import Response
from rest_framework.decorators import action # type: ignore
+from django.db.models import Q, F
+from django.forms.models import model_to_dict
from typing import Any
@@ -38,6 +40,8 @@ class ActionViewSet(viewsets.ModelViewSet):
return Response(data={'detail': 'event already exists'}, status=400)
for step in steps:
+ if step.get('isNew'):
+ step.pop('isNew')
ActionStep.objects.create(
action=action,
**step
@@ -66,4 +70,21 @@ class ActionViewSet(viewsets.ModelViewSet):
action=action,
**step
)
- return Response(ActionSerializer(action).data)
\ No newline at end of file
+ return Response(ActionSerializer(action).data)
+
+
+
+
+ def list(self, request: request.Request, *args: Any, **kwargs: Any) -> Response:
+ actions = self.get_queryset()
+ actions_list = []
+ for action in actions:
+ count = Event.objects.filter_by_action(action)
+ actions_list.append({
+ 'id': action.pk,
+ 'name': action.name,
+ 'count': count
+ })
+ return Response({'results': actions_list})
+
+
\ No newline at end of file
diff --git a/posthog/api/capture.py b/posthog/api/capture.py
index eea17ce8ae4..22d9f0577e5 100644
--- a/posthog/api/capture.py
+++ b/posthog/api/capture.py
@@ -50,7 +50,7 @@ def get_event(request):
if elements:
Element.objects.bulk_create([
Element(
- el_text=el.get('$el_text'),
+ text=el.get('$el_text'),
tag_name=el['tag_name'],
href=el.get('attr__href'),
attr_id=el.get('attr__id'),
diff --git a/posthog/api/event.py b/posthog/api/event.py
index 89d5a2db7f0..9242e5c2ed2 100644
--- a/posthog/api/event.py
+++ b/posthog/api/event.py
@@ -8,7 +8,7 @@ from typing import Any
class ElementSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Element
- fields = ['el_text', 'tag_name', 'href', 'attr_id', 'nth_child', 'nth_of_type', 'attributes', 'order']
+ fields = ['text', 'tag_name', 'href', 'attr_id', 'nth_child', 'nth_of_type', 'attributes', 'order']
class EventSerializer(serializers.HyperlinkedModelSerializer):
person = serializers.SerializerMethodField()
@@ -74,13 +74,13 @@ class EventViewSet(viewsets.ModelViewSet):
def elements(self, request) -> response.Response:
elements = Element.objects.filter(team=request.user.team_set.get())\
.filter(tag_name__in=Element.USEFUL_ELEMENTS)\
- .values('tag_name', 'el_text', 'order')\
+ .values('tag_name', 'text', 'order')\
.annotate(count=Count('event'))\
.order_by('-count')
return response.Response([{
- 'name': '%s with text "%s"' % (el['tag_name'], el['el_text']),
+ 'name': '%s with text "%s"' % (el['tag_name'], el['text']),
'count': el['count'],
'common': el
} for el in elements])
\ No newline at end of file
diff --git a/posthog/api/test/test_action.py b/posthog/api/test/test_action.py
index 6ec88cd3a65..ee9e590eebf 100644
--- a/posthog/api/test/test_action.py
+++ b/posthog/api/test/test_action.py
@@ -1,5 +1,5 @@
from .base import BaseTest
-from posthog.models import Action, ActionStep
+from posthog.models import Action, ActionStep, Event, Element
class TestAction(BaseTest):
TESTS_API = True
diff --git a/posthog/api/test/test_event.py b/posthog/api/test/test_event.py
index 1cef295850e..f104eed9c6d 100644
--- a/posthog/api/test/test_event.py
+++ b/posthog/api/test/test_event.py
@@ -14,7 +14,7 @@ class TestEvents(BaseTest):
event1 = Event.objects.create(team=self.team, properties={"distinct_id": "2"}, ip='8.8.8.8')
Event.objects.create(team=self.team, properties={"distinct_id": 'some-random-uid'}, ip='8.8.8.8')
Event.objects.create(team=self.team, properties={"distinct_id": 'some-other-one'}, ip='8.8.8.8')
- Element.objects.create(tag_name='button', el_text='something', nth_child=0, nth_of_type=0, event=event1, order=0, team=self.team)
+ Element.objects.create(tag_name='button', text='something', nth_child=0, nth_of_type=0, event=event1, order=0, team=self.team)
response = self.client.get('/api/event/?distinct_id=2').json()
@@ -40,10 +40,10 @@ class TestEvents(BaseTest):
event2 = Event.objects.create(team=self.team, ip='8.8.8.8')
event3 = Event.objects.create(team=self.team, ip='8.8.8.8')
event4 = Event.objects.create(team=self.team, ip='8.8.8.8')
- Element.objects.create(tag_name='button', el_text='something', nth_child=0, nth_of_type=0, event=event1, order=0, team=self.team)
- Element.objects.create(tag_name='button', el_text='something', nth_child=0, nth_of_type=0, event=event2, order=0, team=self.team)
- Element.objects.create(tag_name='button', el_text='something else', nth_child=0, nth_of_type=0, event=event3, order=0, team=self.team)
- Element.objects.create(tag_name='input', el_text='', nth_child=0, nth_of_type=0, event=event3, order=0, team=self.team)
+ Element.objects.create(tag_name='button', text='something', nth_child=0, nth_of_type=0, event=event1, order=0, team=self.team)
+ Element.objects.create(tag_name='button', text='something', nth_child=0, nth_of_type=0, event=event2, order=0, team=self.team)
+ Element.objects.create(tag_name='button', text='something else', nth_child=0, nth_of_type=0, event=event3, order=0, team=self.team)
+ Element.objects.create(tag_name='input', text='', nth_child=0, nth_of_type=0, event=event3, order=0, team=self.team)
response = self.client.get('/api/event/elements/').json()
self.assertEqual(response[0]['name'], 'button with text "something"')
diff --git a/posthog/migrations/0009_auto_20200127_0018.py b/posthog/migrations/0009_auto_20200127_0018.py
new file mode 100644
index 00000000000..c8e59408d04
--- /dev/null
+++ b/posthog/migrations/0009_auto_20200127_0018.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.7 on 2020-01-27 00:18
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('posthog', '0008_action_actionstep'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='element',
+ old_name='el_text',
+ new_name='text',
+ ),
+ ]
diff --git a/posthog/models.py b/posthog/models.py
index af8b699c6a7..471e1ee527e 100644
--- a/posthog/models.py
+++ b/posthog/models.py
@@ -3,8 +3,10 @@ from django.contrib.postgres.fields import JSONField, ArrayField
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.dispatch import receiver
+from django.forms.models import model_to_dict
import secrets
+import re
class User(AbstractUser):
@@ -23,8 +25,86 @@ def create_team_signup_token(sender, instance, created, **kwargs):
instance.api_token = secrets.token_urlsafe(10)
instance.save()
+class EventManager(models.Manager):
+ def _handle_nth_child(self, index, tag):
+ nth_child_regex = r"([a-z]+):nth-child\(([0-9]+)\)"
+ nth_child = re.match(nth_child_regex, tag)
+ self.where.append("AND E{}.tag_name = %s".format(index))
+ self.params.append(nth_child[1])
+ self.where.append("AND E{}.nth_child = {}".format(index, nth_child[2]))
+
+ def _handle_id(self, index, tag):
+ id_regex = r"\[id=\'(.*)']"
+ result = re.match(id_regex, tag)
+ self.where.append("AND E{}.attr_id = %s".format(index))
+ self.params.append(result[1])
+
+ def _filter_selector(self, filters):
+ selector = filters.pop('selector')
+ tags = selector.split(' > ')
+ tags.reverse()
+ for index, tag in enumerate(tags):
+ if 'nth-child' in tag:
+ self._handle_nth_child(index, tag)
+ elif 'id=' in tag:
+ self._handle_id(index, tag)
+ else:
+ self.where.append("AND E{}.tag_name = %s".format(index))
+ self.params.append(tag)
+ if index > 0:
+ self.joins.append('INNER JOIN posthog_element E{0} ON (posthog_event.id = E{0}.event_id)'.format(index))
+ self.where.append('AND E{0}.order = (( E{1}.order + 1))'.format(index, index-1))
+
+ def _filters(self, filters):
+ for key, value in filters.items():
+ if key != 'action' and key != 'id' and key != 'selector' and value:
+ self.where.append('AND E0.{} = %s'.format(key))
+ self.params.append(value)
+
+ def _step(self, step):
+ filters = model_to_dict(step)
+ self.where.append(' OR (1=1 ')
+ if filters['selector']:
+ self._filter_selector(filters)
+ self._filters(filters)
+ self.where.append(')')
+
+ def _select(self, count):
+ if count:
+ return "SELECT COUNT(posthog_event.id) as id FROM posthog_event "
+ else:
+ return """
+ SELECT "posthog_event"."id",
+ "posthog_event"."team_id",
+ "posthog_event"."event",
+ "posthog_event"."properties",
+ "posthog_event"."elements",
+ "posthog_event"."timestamp",
+ "posthog_event"."ip"
+ FROM "posthog_event" """
+
+ def filter_by_action(self, action, count=False):
+ query = self._select(count=count)
+
+ self.joins = ['INNER JOIN posthog_element E0 ON (posthog_event.id = E0.event_id)']
+ self.where = []
+ self.params = []
+
+ for step in action.steps.all():
+ self._step(step)
+
+ query += ' '.join(self.joins)
+ query += ' WHERE 1=2 '
+ query += ' '.join(self.where)
+ events = Event.objects.raw(query, self.params)
+ if count:
+ return events[0].id # bit of a hack to get the total count here
+ return events
+
+
class Event(models.Model):
+ objects = EventManager()
team: models.ForeignKey = models.ForeignKey(Team, on_delete=models.CASCADE)
event: models.CharField = models.CharField(max_length=200, null=True, blank=True)
properties: JSONField = JSONField(default=dict)
@@ -42,7 +122,7 @@ class Person(models.Model):
class Element(models.Model):
USEFUL_ELEMENTS = ['a', 'button', 'input', 'select', 'textarea', 'label']
- el_text: models.CharField = models.CharField(max_length=400, null=True, blank=True)
+ text: models.CharField = models.CharField(max_length=400, null=True, blank=True)
tag_name: models.CharField = models.CharField(max_length=400, null=True, blank=True)
href: models.CharField = models.CharField(max_length=400, null=True, blank=True)
attr_id: models.CharField = models.CharField(max_length=400, null=True, blank=True)
diff --git a/posthog/test/__init__.py b/posthog/test/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/posthog/test/test_event_model.py b/posthog/test/test_event_model.py
new file mode 100644
index 00000000000..43776abcda7
--- /dev/null
+++ b/posthog/test/test_event_model.py
@@ -0,0 +1,63 @@
+from posthog.models import Event, Element, Action, ActionStep
+from posthog.api.test.base import BaseTest
+
+class TestEvent(BaseTest):
+ def test_filter_with_selectors(self):
+ user = self._create_user('timg')
+ event1 = Event.objects.create(team=self.team, ip="8.8.8.8")
+ Element.objects.create(tag_name='div', event=event1, team=self.team, nth_child=0, nth_of_type=0, order=0)
+ Element.objects.create(tag_name='a', href='/a-url', event=event1, team=self.team, nth_child=1, nth_of_type=0, order=1)
+
+ event2 = Event.objects.create(team=self.team, ip="8.8.8.8")
+ Element.objects.create(tag_name='a', event=event2, team=self.team, nth_child=2, nth_of_type=0, order=0, attr_id='someId')
+ Element.objects.create(tag_name='div', event=event2, team=self.team, nth_child=0, nth_of_type=0, order=1)
+ # make sure elements don't get double counted if they're part of the same event
+ Element.objects.create(href='/a-url-2', event=event2, team=self.team, nth_child=0, nth_of_type=0, order=2)
+
+ # test direct decendant ordering
+ action1 = Action.objects.create(team=self.team)
+ ActionStep.objects.create(action=action1, tag_name='a', selector='div > a')
+
+ events = Event.objects.filter_by_action(action1)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0], event2)
+
+ # test :nth-child()
+ action2 = Action.objects.create(team=self.team)
+ ActionStep.objects.create(action=action2, tag_name='a', selector='div > a:nth-child(2)')
+
+ events = Event.objects.filter_by_action(action2)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0], event2)
+
+ # test [id='someId']
+ action3 = Action.objects.create(team=self.team)
+ ActionStep.objects.create(action=action3, selector="[id='someId']")
+
+ events = Event.objects.filter_by_action(action3)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0], event2)
+
+ def test_with_normal_filters(self):
+ user = self._create_user('tim')
+ self.client.force_login(user)
+ event1 = Event.objects.create(team=self.team, ip="8.8.8.8")
+ Element.objects.create(tag_name='a', href='/a-url', text='some_text', event=event1, team=self.team, nth_child=0, nth_of_type=0, order=0)
+
+ event2 = Event.objects.create(team=self.team, ip="8.8.8.8")
+ Element.objects.create(tag_name='a', href='/a-url-2', text='some_other_text', event=event2, team=self.team, nth_child=0, nth_of_type=0, order=0)
+ # make sure elements don't get double counted if they're part of the same event
+ Element.objects.create(tag_name='div', text='some_other_text', event=event2, team=self.team, nth_child=0, nth_of_type=0, order=1)
+
+ action1 = Action.objects.create(team=self.team)
+ ActionStep.objects.create(action=action1, href='/a-url', tag_name='a')
+ ActionStep.objects.create(action=action1, href='/a-url-2')
+
+
+ events = Event.objects.filter_by_action(action1)
+ self.assertEqual(events[0], event1)
+ self.assertEqual(len(events), 2)
+
+ # test count
+ events = Event.objects.filter_by_action(action1, count=True)
+ self.assertEqual(events, 2)
\ No newline at end of file