0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-22 08:40:03 +01:00

Closes #111 Search through user properties

This commit is contained in:
Tim Glaser 2020-02-17 16:53:41 -08:00
parent 27b6afe161
commit 50807d063b
6 changed files with 92 additions and 22 deletions

View File

@ -1,6 +1,8 @@
{
"name": "PostHog - Product analytics",
"website": "https://www.posthog.com",
"repository": "https://github.com/posthog/posthog",
"logo": "https://posthog.com/wp-content/uploads/2020/02/Group-10.png",
"environments": {
"review": {
"scripts": {
@ -35,6 +37,5 @@
"SECRET_KEY": {
"generator": "secret"
}
},
"logo": "https://posthog.com/wp-content/uploads/elementor/thumbs/Group-9-okswmm1u72v1i0der1e2tk8jeqjrowpdyt4euc6pu6.png"
}
}

View File

@ -2,18 +2,22 @@ import React, { Component } from 'react'
import api from './Api';
import { Link } from 'react-router-dom';
import moment from 'moment';
import { debounce } from './utils';
let toParams = (obj) => Object.entries(obj).map(([key, val]) => `${key}=${encodeURIComponent(val)}`).join('&')
export default class People extends Component {
constructor(props) {
super(props)
this.state = {}
this.state = {loading: true}
this.FilterLink = this.FilterLink.bind(this);
this.fetchPeople.call(this);
this.fetchPeople = this.fetchPeople.bind(this);
this.debounceFetchPeople = debounce(this.fetchPeople.bind(this), 250)
this.fetchPeople();
this.clickNext = this.clickNext.bind(this);
}
fetchPeople() {
api.get('api/person/?include_last_event=1').then((data) => this.setState({people: data.results}))
fetchPeople(search) {
api.get('api/person/?include_last_event=1&' + (!!search ? 'search=' + search : '')).then((data) => this.setState({people: data.results, hasNext: data.next, loading: false}))
}
FilterLink(props) {
let filters = {...this.state.filters};
@ -27,21 +31,39 @@ export default class People extends Component {
}}
>{typeof props.value === 'object' ? JSON.stringify(props.value) : props.value}</Link>
}
clickNext() {
let { people, hasNext } = this.state;
this.setState({hasNext: false})
api.get(hasNext).then((olderPeople) => {
this.setState({people: [...people, ...olderPeople.results], hasNext: olderPeople.next, loading: false})
});
}
render() {
let { hasNext, people, loading } = this.state;
let exampleEmail = (people && people.map((person) => person.properties.email).filter((d) => d)[0]) || 'example@gmail.com';
return (
<div>
<h1>Users</h1>
{people && <input
className='form-control'
name='search'
autoFocus
onKeyDown={(e) => e.keyCode == "13" ? this.fetchPeople(e.target.value) : this.debounceFetchPeople(e.target.value)}
placeholder={people && "Try " + exampleEmail + " or has:email"} />}<br />
<table className='table'>
{loading && <div className='loading-overlay'><div></div></div>}
<tbody>
<tr><th>Person</th><th>Last seen</th></tr>
{this.state.people && this.state.people.length == 0 && <tr><td colSpan="2">We haven't seen any data yet. If you haven't integrated PostHog, <Link to='/setup'>click here to set PostHog up on your app</Link></td></tr>}
{this.state.people && this.state.people.map((person) => [
<tr><th>Person</th><th>Last seen</th><th>First seen</th></tr>
{people && people.length == 0 && <tr><td colSpan="2">We haven't seen any data yet. If you haven't integrated PostHog, <Link to='/setup'>click here to set PostHog up on your app</Link></td></tr>}
{people && people.map((person) => [
<tr key={person.id} className='cursor-pointer' onClick={() => this.setState({personSelected: person.id})}>
<td><Link to={'/person/' + person.distinct_ids[0]}>{person.name}</Link></td>
<td>{person.last_event && moment(person.last_event.timestamp).fromNow()}</td>
<td title={person.created_at}>{moment(person.created_at).fromNow()}</td>
</tr>,
this.state.personSelected == person.id && <tr key={person.id + '_open'}>
<td colSpan="4">
{Object.keys(person.properties).length == 0 && "This person has no properties."}
<div className='d-flex flex-wrap flex-column' style={{height: 200}}>
{Object.keys(person.properties).sort().map((key) => <div style={{flex: '0 1 '}} key={key}>
<strong>{key}:</strong> <this.FilterLink property={key} value={person.properties[key]} />
@ -52,6 +74,9 @@ export default class People extends Component {
])}
</tbody>
</table>
{people && people.length > 0 && <button className='btn btn-primary' onClick={this.clickNext} style={{margin: '2rem auto 15rem', display: 'block'}} disabled={!hasNext}>
Load more events
</button>}
</div>
)
}

View File

@ -15,6 +15,20 @@ export let fromParams = () => window.location.search != '' ? window.location.sea
export let colors = ['success', 'secondary', 'warning', 'primary', 'danger', 'info', 'dark', 'light']
export let percentage = (division) => division.toLocaleString(undefined, {style: 'percent', maximumFractionDigits: 2})
export let debounce = function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
export function Card(props) {
return <div {...props} className={'card ' + props.className} style={props.style}>

4
posthog/api/base.py Normal file
View File

@ -0,0 +1,4 @@
from rest_framework.pagination import CursorPagination as RestCursorPagination
class CursorPagination(RestCursorPagination):
ordering = '-created_at'

View File

@ -1,9 +1,10 @@
from posthog.models import Event, Team, Person, PersonDistinctId
from rest_framework import serializers, viewsets, response
from rest_framework import serializers, viewsets, response, request
from rest_framework.decorators import action
from django.db.models import Q, Prefetch
from django.db.models import Q, Prefetch, QuerySet
from .event import EventSerializer
from typing import Union
from .base import CursorPagination
class PersonSerializer(serializers.HyperlinkedModelSerializer):
last_event = serializers.SerializerMethodField()
@ -11,7 +12,7 @@ class PersonSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Person
fields = ['id', 'name', 'distinct_ids', 'properties', 'last_event']
fields = ['id', 'name', 'distinct_ids', 'properties', 'last_event', 'created_at']
def get_last_event(self, person: Person) -> Union[dict, None]:
if not self.context['request'].GET.get('include_last_event'):
@ -32,20 +33,33 @@ class PersonSerializer(serializers.HyperlinkedModelSerializer):
class PersonViewSet(viewsets.ModelViewSet):
queryset = Person.objects.all()
serializer_class = PersonSerializer
pagination_class = CursorPagination
def _filter_request(self, request: request.Request, queryset: QuerySet) -> QuerySet:
if request.GET.get('id'):
request.GET.pop('id')
people = request.GET['id'].split(',')
queryset = queryset.filter(id__in=people)
if request.GET.get('search'):
parts = request.GET['search'].split(' ')
contains = []
for part in parts:
if ':' in part:
queryset = queryset.filter(properties__has_key=part.split(':')[1])
else:
contains.append(part)
queryset = queryset.filter(properties__icontains=' '.join(contains))
queryset = queryset.prefetch_related(Prefetch('persondistinctid_set', to_attr='distinct_ids_cache'))
return queryset
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(team=self.request.user.team_set.get())
if self.action == 'list':
if self.request.GET.get('id'):
people = self.request.GET['id'].split(',')
queryset = queryset.filter(id__in=people)
queryset = queryset.prefetch_related(Prefetch('persondistinctid_set', to_attr='distinct_ids_cache'))
queryset = self._filter_request(self.request, queryset)
return queryset.order_by('-id')
@action(methods=['GET'], detail=False)
def by_distinct_id(self, request):
# sometimes race condition creates 2
person = self.get_queryset().filter(persondistinctid__distinct_id=str(request.GET['distinct_id'])).first()
person = self.get_queryset().get(persondistinctid__distinct_id=str(request.GET['distinct_id']))
return response.Response(PersonSerializer(person, context={'request': request}).data)

View File

@ -12,5 +12,17 @@ class TestPerson(BaseTest):
Person.objects.create(team=self.team, distinct_ids=['distinct_id_3', 'anonymous_id_3'])
Event.objects.create(team=self.team, distinct_id='distinct_id_3')
with self.assertNumQueries(6):
response = self.client.get('/api/person/').json()
with self.assertNumQueries(5):
response = self.client.get('/api/person/').json()
def test_search(self):
Person.objects.create(team=self.team, distinct_ids=['distinct_id'], properties={'email': 'someone@gmail.com'})
Person.objects.create(team=self.team, distinct_ids=['distinct_id_2'], properties={'email': 'another@gmail.com'})
Person.objects.create(team=self.team, distinct_ids=['distinct_id_3'], properties={})
response = self.client.get('/api/person/?search=has:email').json()
self.assertEqual(len(response['results']), 2)
response = self.client.get('/api/person/?search=another@gm').json()
self.assertEqual(len(response['results']), 1)