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:
parent
27b6afe161
commit
50807d063b
5
app.json
5
app.json
@ -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"
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
4
posthog/api/base.py
Normal file
@ -0,0 +1,4 @@
|
||||
from rest_framework.pagination import CursorPagination as RestCursorPagination
|
||||
|
||||
class CursorPagination(RestCursorPagination):
|
||||
ordering = '-created_at'
|
@ -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)
|
@ -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)
|
Loading…
Reference in New Issue
Block a user