├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── backfill.go ├── config ├── development.yml └── service_account_key.example.json ├── db_clear.go ├── db_init.go ├── db_reset.go ├── doc.go ├── doc └── periscope_dashboard_queries.sql ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── pkg ├── apis │ ├── apis.go │ └── apis_test.go ├── conf │ └── conf.go ├── data │ ├── data.go │ ├── meetings.go │ ├── setup.go │ ├── userMeetingMins.go │ └── users.go ├── metrics │ └── metrics.go ├── setup │ ├── setup.go │ ├── userdepartments │ │ ├── user-departments.csv │ │ └── userDepartments.go │ └── users.go └── util │ └── util.go └── scratch.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | .idea/ 14 | 15 | #glide 16 | vendor 17 | 18 | config/service_account_key.json 19 | config/production.yml -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage build to get a lean go container: https://docs.docker.com/engine/userguide/eng-image/multistage-build 2 | 3 | FROM golang as builder 4 | WORKDIR /app 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | COPY pkg ./pkg 8 | COPY main.go ./ 9 | RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on go build -a -installsuffix cgo -o meetrics . 10 | 11 | FROM alpine:latest 12 | RUN apk --no-cache add ca-certificates 13 | WORKDIR /root/ 14 | COPY --from=builder /app/meetrics . 15 | COPY config ./config 16 | CMD ["./meetrics"] 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | OK := $(shell printf "\e[2D\e[32m✅ ") 4 | WARN := $(shell printf "\e[2D\e[33m⚠️ \e[1m") 5 | INFO := $(shell printf "\e[2D\e[36mℹ️ ") 6 | ERROR := $(shell printf "\e[2D\e[31m❗ ") 7 | END := $(shell printf "\e[0m") 8 | 9 | .PHONY: init image database backfill 10 | 11 | init: 12 | docker-compose up -d mysql 13 | go run db_init.go 14 | # $(OK) init $(END) 15 | 16 | image: 17 | docker build -t chasdevs/meetrics . 18 | # $(OK) image $(END) 19 | 20 | database: 21 | go run db_reset.go 22 | # $(OK) database $(END) 23 | 24 | backfill: 25 | go run backfill.go 26 | # $(OK) backfill $(END) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | **Update:** *Meetrics is now a real product! Check out [meetrics.io](https://meetrics.io) for an easy way to monitor your organization's meeting health.* 3 | 4 | # Meetrics 5 | 6 | Basic Google Calendar meeting data tracking within a Google organization. This is the codebase behind [this blog post](https://engineering.videoblocks.com/analyzing-meeting-metrics-using-the-google-calendar-api-3c76c9f8ffea) which describes how we analyzed meeting data at [Storyblocks](https://www.storyblocks.com). 7 | 8 | ![Meeting Data Graph](https://miro.medium.com/max/3868/1*K1mHm1dBwsQGCvs9A1xs6A.png) 9 | 10 | ## Requirements 11 | 12 | - A Google Cloud Service Account 13 | - Needs the following scopes: _calendar.readonly_, _admin.directory.user.readonly_ 14 | - Need a valid `service_account_key.json` file. 15 | - May require admin access to your organization's G Suite account. 16 | 17 | ## Setup 18 | 19 | ### Preparing Google Calendar 20 | 21 | A "service account" is used to communicate with the Google Calendar API. Their [documentation](https://developers.google.com/identity/protocols/OAuth2ServiceAccount) best describes how to do this. The basic steps I took were the following: 22 | 23 | 1. Create a project in the Google APIs [dashboard](https://console.developers.google.com/apis/dashboard) to hold the service account. 24 | 1. Create the service account in the [IAM & admin](https://console.developers.google.com/iam-admin/serviceaccounts) section of your project. 25 | 1. Delegate domain-wide authority to the service account. 26 | 1. Log into the G Suite [Admin](http://admin.google.com/) section (as an admin user) to authorize the service account for API scopes. For listing calendar information, you need the _calendar.readonly_ scope: `https://www.googleapis.com/auth/calendar.readonly`. For listing users in the domain, you need the _admin.directory.user.readonly_ scope: `https://www.googleapis.com/auth/admin.directory.user.readonly`. 27 | 1. Go to the [credentials page](https://console.developers.google.com/apis/credentials) in the Google APIs dashboard for your project, click **Create credentials > Service account key**, select your service account and JSON as the key type, and click **Create**. This will download your credentials JSON file containing your private key which will be used in Google API SDKs. 28 | 29 | To connect, place your `service_account_key.json` file inside this application's _config/_ directory (it is git-ignored). 30 | 31 | Lastly, you will need to choose a "subject" to query Google Calendar, which is the email address of a user in your organization who can see the calendars of your users (I used mine). This is set via the `google.subject` config variable. 32 | 33 | ### Configuration 34 | 35 | You MUST put a valid Google _service_account_key.json_ file in the _config/_ directory. 36 | 37 | The following configs are set via environment variables or _config/production.yml_ file. 38 | 39 | - GOOGLE_DOMAIN 40 | - (required) The domain of the Google account. 41 | - GOOGLE_SUBJECT 42 | - (required) The email of the account to query Google's Calendar API. 43 | 44 | ### Populating the Users Table 45 | 46 | I found that a manually-curated whitelist of users was the best way to get accurate data on employees, since there are multiple aliases and miscellaneous emails in any organization. It was also the best way to attach a department to each user. 47 | 48 | The `setup.PopulateUsersFromCsv` function reads in the [user-departments.csv](./pkg/setup/userdepartments/user-departments.csv) file and populates the users table. Edit this file to reflect your organization. 49 | 50 | If you do not want to manually enter the user information, the `setup.PopulateUsersFromApi` method can be used instead, but this does not include department information and may include emails you do not wish to analyze. 51 | 52 | 53 | ## Development 54 | 55 | ``` 56 | # Start mysql using docker-compose and initialize local database. 57 | make init 58 | ``` 59 | 60 | ### Verify Configuration 61 | 62 | You can verify that the code is able to hit the Google APIs by running the tests for the `apis` package. 63 | ``` 64 | go test ./pkg/apis 65 | ``` 66 | 67 | ### Docker 68 | 69 | Build and push the docker image. Jenkins pulls the docker image each night and runs the script to populate the database with meeting information. 70 | 71 | ```bash 72 | docker build -t chasdevs/meetrics . 73 | ``` 74 | 75 | You can run locally from the docker image: 76 | 77 | ```bash 78 | docker run --rm -e DB_HOST="host.docker.internal" -v `pwd`:/app chasdevs/meetrics 79 | ``` 80 | 81 | Or against prod (must have a compiled image with a production config file): 82 | ```bash 83 | docker run --rm -e ENV=production chasdevs/meetrics 84 | ``` 85 | -------------------------------------------------------------------------------- /backfill.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/jinzhu/gorm/dialects/mysql" 5 | 6 | "github.com/chasdevs/meetrics/pkg/data" 7 | "github.com/chasdevs/meetrics/pkg/metrics" 8 | "github.com/chasdevs/meetrics/pkg/util" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func init() { 13 | log.SetLevel(log.InfoLevel) 14 | data.Init() 15 | } 16 | 17 | // Main 18 | 19 | func main() { 20 | computeLastDays(365) 21 | } 22 | 23 | func computeLastDays(days int) { 24 | for i := 0; i < days; i++ { 25 | date := util.BeginningOfDay(i) 26 | if util.IsWeekday(date) { 27 | log.WithField("date", date).Info("Compiling metrics for date.") 28 | metrics.CompileMetrics(date) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/development.yml: -------------------------------------------------------------------------------- 1 | --- 2 | db: 3 | host: localhost 4 | port: 3306 5 | user: meetrics 6 | password: password 7 | database: meetrics 8 | rootUser: root 9 | rootPassword: password 10 | rootDatabase: mysql 11 | 12 | google: 13 | subject: admin@gmail.com 14 | domain: yourdomain.com 15 | -------------------------------------------------------------------------------- /config/service_account_key.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "project-name-1337", 4 | "private_key_id": "abcdefghijklmnopqrstuvwxyz12345678901234", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", 6 | "client_email": "meetrics-service-account@project-name-1337.iam.gserviceaccount.com", 7 | "client_id": "12345678901234567890", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://accounts.google.com/o/oauth2/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/meetrics-service-account%40project-name-1337.iam.gserviceaccount.com" 12 | } 13 | -------------------------------------------------------------------------------- /db_clear.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/data" 5 | ) 6 | 7 | func init() { 8 | data.Init() 9 | } 10 | 11 | func main() { 12 | data.Mgr.ClearMeetings() 13 | data.Mgr.ClearUserMeetingMins() 14 | } 15 | -------------------------------------------------------------------------------- /db_init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/setup" 5 | ) 6 | 7 | func main() { 8 | setup.Setup() 9 | setup.Migrate() 10 | setup.PopulateUsersFromCsv() 11 | } 12 | -------------------------------------------------------------------------------- /db_reset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/setup" 5 | ) 6 | 7 | func main() { 8 | setup.TearDown() 9 | setup.Setup() 10 | setup.Migrate() 11 | setup.PopulateUsersFromCsv() 12 | } 13 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Documentation for meetrics project. 3 | */ 4 | package main 5 | -------------------------------------------------------------------------------- /doc/periscope_dashboard_queries.sql: -------------------------------------------------------------------------------- 1 | -- Time spent in meetings 2 | SET @minsInWorkDay = 7*60; # 7 hours of "work time" 3 | SELECT 4 | umm.date, 5 | AVG(umm.mins2_plus/@minsInWorkDay*100) AS "% time in meetings", 6 | AVG(umm.mins1/@minsInWorkDay*100) AS "% time in 1:1s", 7 | AVG((CAST(@minsInWorkDay AS SIGNED) - CAST(umm.mins1 + umm.mins2_plus AS SIGNED))/@minsInWorkDay*100) AS "% crank time" 8 | FROM user_meeting_mins umm 9 | JOIN users u 10 | ON umm.user_id = u.id 11 | GROUP BY YEARWEEK(umm.date); 12 | 13 | 14 | -- Avg meeting time by department 15 | SET @minsInWorkDay = 7*60; # 7 hours of "work time" 16 | SELECT 17 | u.department, 18 | AVG(umm.mins2_plus/@minsInWorkDay*100) AS "% time in meetings", 19 | AVG(umm.mins1/@minsInWorkDay*100) AS "% time in 1:1s", 20 | AVG((CAST(@minsInWorkDay AS SIGNED) - CAST(umm.mins1 + umm.mins2_plus AS SIGNED))/@minsInWorkDay*100) AS "% crank time" 21 | FROM user_meeting_mins umm 22 | JOIN users u 23 | ON umm.user_id = u.id 24 | WHERE u.department NOT IN ("Enterprise") 25 | GROUP BY u.department; 26 | 27 | -- Biggest recurring meetings 28 | SELECT 29 | m.name, 30 | m.attendees, 31 | m.mins, 32 | m.frequency_per_month * m.mins * m.attendees / 60 AS hrs_per_month 33 | FROM meetings m 34 | WHERE m.name NOT LIKE "%football%" AND m.name NOT LIKE "%softball%" 35 | ORDER BY hrs_per_month DESC 36 | LIMIT 5; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | mysql: 4 | image: mysql 5 | ports: [3306:3306] 6 | environment: 7 | MYSQL_ROOT_PASSWORD: password -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chasdevs/meetrics 2 | 3 | go 1.13 4 | 5 | require ( 6 | cloud.google.com/go v0.0.0-20170926000123-a51adbf9636a // indirect 7 | github.com/BurntSushi/toml v0.3.1 // indirect 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 // indirect 10 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect 11 | github.com/go-sql-driver/mysql v1.4.1 12 | github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783 // indirect 13 | github.com/jinzhu/gorm v0.0.0-20160404144928-5174cc5c242a 14 | github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d // indirect 15 | github.com/jinzhu/now v1.1.1 // indirect 16 | github.com/kr/pretty v0.1.0 // indirect 17 | github.com/lib/pq v1.2.0 // indirect 18 | github.com/magiconair/properties v0.0.0-20170902060319-8d7837e64d3c // indirect 19 | github.com/mattn/go-sqlite3 v1.11.0 // indirect 20 | github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992 // indirect 21 | github.com/onsi/ginkgo v1.10.3 // indirect 22 | github.com/onsi/gomega v1.7.1 // indirect 23 | github.com/pelletier/go-toml v1.0.1 // indirect 24 | github.com/sirupsen/logrus v1.0.3 25 | github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1 // indirect 26 | github.com/spf13/cast v1.1.0 27 | github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386 // indirect 28 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1 // indirect 29 | github.com/spf13/viper v1.0.0 30 | github.com/stretchr/testify v1.4.0 31 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd 32 | golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2 33 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect 34 | google.golang.org/api v0.0.0-20170924000319-cec5cc05d576 35 | google.golang.org/appengine v0.0.0-20170921170648-24e4144ec923 // indirect 36 | gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect 37 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 38 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.0.0-20170926000123-a51adbf9636a h1:NtShIlqvLwavIF5PqAX2z2FO7S/C0z4URlKQU6vMEbo= 2 | cloud.google.com/go v0.0.0-20170926000123-a51adbf9636a/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 h1:epsH3lb7KVbXHYk7LYGN5EiE0MxcevHU85CKITJ0wUY= 9 | github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 10 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 11 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 12 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 13 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 14 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 15 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 16 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= 17 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 18 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 19 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 20 | github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783 h1:LFTfzwAUSKPijQbJrMWZm/CysECsF/U1UUniUeXxzFw= 21 | github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= 22 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 23 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 24 | github.com/jinzhu/gorm v0.0.0-20160404144928-5174cc5c242a h1:pfPxlCVlKqBRqHpyCxOIKhhB4ERpz02iadDpRVevLm4= 25 | github.com/jinzhu/gorm v0.0.0-20160404144928-5174cc5c242a/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= 26 | github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc= 27 | github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 28 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 29 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 30 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 31 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 32 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 33 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 34 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 35 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 36 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 37 | github.com/magiconair/properties v0.0.0-20170902060319-8d7837e64d3c h1:BDr2SMw3gKp9Xyvp33plTgRPEkE6NralNG0JLuBgkiQ= 38 | github.com/magiconair/properties v0.0.0-20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 39 | github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= 40 | github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 41 | github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992 h1:W7VHAEVflA5/eTyRvQ53Lz5j8bhRd1myHZlI/IZFvbU= 42 | github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 43 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 44 | github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY= 45 | github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 46 | github.com/onsi/gomega v1.7.1 h1:K0jcRCwNQM3vFGh1ppMtDh/+7ApJrjldlX8fA0jDTLQ= 47 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 48 | github.com/pelletier/go-toml v1.0.1 h1:0nx4vKBl23+hEaCOV1mFhKS9vhhBtFYWC7rQY0vJAyE= 49 | github.com/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/sirupsen/logrus v1.0.3 h1:B5C/igNWoiULof20pKfY4VntcIPqKuwEmoLZrabbUrc= 53 | github.com/sirupsen/logrus v1.0.3/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 54 | github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1 h1:9YWfpAdlPISN1kBzsAokT9SbSipcgt/BBM0lI9lawmo= 55 | github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 56 | github.com/spf13/cast v1.1.0 h1:0Rhw4d6C8J9VPu6cjZLIhZ8+aAOHcDvGeKn+cq5Aq3k= 57 | github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 58 | github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386 h1:zBoLErXXAvWnNsu+pWkRYl6Cx1KXmIfAVsIuYkPN6aY= 59 | github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 60 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1 h1:TRYBd3V/2jfUifd2vqT9S1O6mTgEwmgxgfRpI5zx6FU= 61 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 62 | github.com/spf13/viper v1.0.0 h1:RUA/ghS2i64rlnn4ydTfblY8Og8QzcPtCcHvgMn+w/I= 63 | github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 66 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 67 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI= 68 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 69 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= 70 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 71 | golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2 h1:NMHa8RdjXuWXQSB0fW0PAKkX9lHZCRu5FsmPI/IZuS4= 72 | golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 73 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 74 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= 75 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 76 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 77 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 78 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 79 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 80 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 81 | google.golang.org/api v0.0.0-20170924000319-cec5cc05d576 h1:4tWcEkLsFtb9VR2VuJfyBSu5p0kPB6P8VnhV7W+CBRI= 82 | google.golang.org/api v0.0.0-20170924000319-cec5cc05d576/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 83 | google.golang.org/appengine v0.0.0-20170921170648-24e4144ec923 h1:g6eJTZjhToPpV2kztUmYalkPzR1endu41uGUOkIl1bQ= 84 | google.golang.org/appengine v0.0.0-20170921170648-24e4144ec923/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 85 | gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= 86 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 89 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 91 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 92 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= 93 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 94 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 95 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 96 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 97 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 98 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 99 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 100 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/jinzhu/gorm/dialects/mysql" 5 | 6 | "github.com/chasdevs/meetrics/pkg/data" 7 | "github.com/chasdevs/meetrics/pkg/metrics" 8 | "github.com/chasdevs/meetrics/pkg/util" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | /* 13 | * TODO: Identify and save rooms. 14 | * TODO: Organize the code. 15 | * TODO: Add unit tests!!! 16 | * TODO: Link users to meetings in database via the EventMaps. 17 | * TODO: Handle overlapping events for users. 18 | 19 | * TODO: Background reading on NPS to inform the meeting nps question: "was the meeting too long or too short?" (too short => high value!) 20 | * Look for "remnant" windows of 30min or so between meetings. "Swiss cheese factor". Look for "dead space". A single hour is sorta "low value" time. Above 90 min you get higher value. 21 | */ 22 | 23 | // Initialization 24 | 25 | func init() { 26 | log.SetLevel(log.DebugLevel) 27 | data.Init() 28 | } 29 | 30 | // Main 31 | 32 | func main() { 33 | computeYesterday() 34 | } 35 | 36 | func computeYesterday() { 37 | date := util.BeginningOfYesterday() 38 | if util.IsWeekday(date) { 39 | metrics.CompileMetrics(date) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/apis/apis.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/conf" 5 | "log" 6 | "path" 7 | "runtime" 8 | 9 | "google.golang.org/api/calendar/v3" 10 | 11 | "golang.org/x/net/context" 12 | "golang.org/x/oauth2/google" 13 | 14 | "io/ioutil" 15 | "net/http" 16 | 17 | "google.golang.org/api/admin/directory/v1" 18 | ) 19 | 20 | // Calendar returns a client for the Google Calendar API. Provided email is the user who is being impersonated via the service account. 21 | func Calendar(email string) calendar.Service { 22 | 23 | client := getClient(email) 24 | 25 | srv, err := calendar.New(client) 26 | if err != nil { 27 | log.Fatalf("Unable to retrieve calendar client: %v", err) 28 | } 29 | 30 | return *srv 31 | } 32 | 33 | // Admin returns a client for the Google Admin API 34 | func Admin() admin.Service { 35 | client := getClient(conf.GetString("google.subject")) 36 | 37 | srv, err := admin.New(client) 38 | if err != nil { 39 | log.Fatalf("Unable to retrieve admin client: %v", err) 40 | } 41 | 42 | return *srv 43 | } 44 | 45 | func getClient(email string) *http.Client { 46 | return clientFromJwtConfig(email) 47 | } 48 | 49 | func clientFromJwtConfig(email string) *http.Client { 50 | 51 | // Your credentials should be obtained from the Google 52 | // Developer Console (https://console.developers.google.com). 53 | serviceAccountJSON, err := ioutil.ReadFile(serviceAccountFile()) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | jwtConfig, _ := google.JWTConfigFromJSON(serviceAccountJSON, calendar.CalendarReadonlyScope, admin.AdminDirectoryUserReadonlyScope) 59 | jwtConfig.Subject = email 60 | return jwtConfig.Client(context.Background()) 61 | } 62 | 63 | 64 | func serviceAccountFile() string { 65 | _, filename, _, _ := runtime.Caller(1) 66 | filepath := path.Join(path.Dir(filename), "../../config/service_account_key.json") 67 | return filepath 68 | } -------------------------------------------------------------------------------- /pkg/apis/apis_test.go: -------------------------------------------------------------------------------- 1 | package apis_test 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/apis" 5 | "github.com/chasdevs/meetrics/pkg/conf" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestAdminApi(t *testing.T) { 11 | adminApi := apis.Admin() 12 | res, err := adminApi.Users.List().Domain(conf.GetString("google.domain")).Do() 13 | assert.Nil(t, err) 14 | assert.NotNil(t, res) 15 | assert.GreaterOrEqual(t, len(res.Users), 1) 16 | } 17 | 18 | func TestCalendarApi(t *testing.T) { 19 | subject := conf.GetString("google.subject") 20 | calendarApi := apis.Calendar(subject) 21 | res, err := calendarApi.Events.List(subject).Do() 22 | assert.Nil(t, err) 23 | assert.NotNil(t, res) 24 | assert.GreaterOrEqual(t, len(res.Items), 1) 25 | } -------------------------------------------------------------------------------- /pkg/conf/conf.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-sql-driver/mysql" 6 | "github.com/spf13/viper" 7 | "path" 8 | "runtime" 9 | "strings" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var env string 14 | 15 | func init() { 16 | env = initEnv() 17 | viper.SetConfigFile(configFile()) 18 | 19 | // Allow env vars to use underscores for periods 20 | replacer := strings.NewReplacer(".", "_") 21 | viper.SetEnvKeyReplacer(replacer) 22 | viper.AutomaticEnv() 23 | 24 | err := viper.ReadInConfig() 25 | if err != nil { 26 | log.Errorf("Error reading config file. Check to make sure the config/%s.yml file exists.", env) 27 | panic(err) 28 | } 29 | 30 | } 31 | 32 | func MysqlConfig() mysql.Config { 33 | 34 | params := map[string]string{ 35 | "parseTime": "true", 36 | } 37 | 38 | return mysql.Config{ 39 | User: viper.GetString("db.user"), 40 | Passwd: viper.GetString("db.password"), 41 | Net: "tcp", 42 | Addr: fmt.Sprintf("%v:%v", viper.GetString("db.host"), viper.GetString("db.port")), 43 | DBName: viper.GetString("db.database"), Params: params, 44 | AllowNativePasswords: true, 45 | } 46 | } 47 | 48 | func MysqlRootConfig() mysql.Config { 49 | 50 | params := map[string]string{ 51 | "parseTime": "true", 52 | } 53 | 54 | return mysql.Config{ 55 | User: viper.GetString("db.rootUser"), 56 | Passwd: viper.GetString("db.rootPassword"), 57 | Net: "tcp", 58 | Addr: fmt.Sprintf("%v:%v", viper.GetString("db.host"), viper.GetString("db.port")), 59 | DBName: viper.GetString("db.rootDatabase"), Params: params, 60 | AllowNativePasswords: true, 61 | } 62 | 63 | } 64 | 65 | func GetString(path string) string { 66 | return viper.GetString(path) 67 | } 68 | 69 | // Private 70 | 71 | func initEnv() string { 72 | viper.SetDefault("env", "development") 73 | _ = viper.BindEnv("env") 74 | 75 | env := viper.GetString("env") 76 | if !stringInSlice(env, []string{"development", "production", "prod"}) { 77 | panic("Invalid environment: " + env) 78 | } 79 | 80 | if env == "prod" { 81 | env = "production" 82 | } 83 | 84 | log.Infof("Environment: %v\n", env) 85 | 86 | return env 87 | } 88 | 89 | func configFile() string { 90 | _, filename, _, _ := runtime.Caller(1) 91 | filepath := path.Join(path.Dir(filename), fmt.Sprintf("../../config/%v.yml", env)) 92 | return filepath 93 | } 94 | 95 | // UTIL 96 | 97 | func stringInSlice(a string, list []string) bool { 98 | for _, b := range list { 99 | if b == a { 100 | return true 101 | } 102 | } 103 | return false 104 | } 105 | -------------------------------------------------------------------------------- /pkg/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/conf" 5 | "github.com/jinzhu/gorm" 6 | _ "github.com/jinzhu/gorm/dialects/mysql" 7 | "time" 8 | ) 9 | 10 | type Manager interface { 11 | CreateMeeting(meeting *Meeting) 12 | CreateMeetings(meetings []*Meeting) 13 | ClearMeetings() 14 | 15 | CreateUserMeetingMins(date time.Time, user User, meetingMins map[string]uint) 16 | ClearUserMeetingMins() 17 | 18 | AddAllUsers(users []*User) 19 | GetAllUsers() []User 20 | GetUserByEmail(email string) User 21 | GetUserById(id int) User 22 | } 23 | 24 | type manager struct { 25 | db *gorm.DB 26 | } 27 | 28 | var Mgr Manager 29 | 30 | // When lowercase, init() will run during package initialization 31 | func Init() { 32 | config := conf.MysqlConfig() 33 | db, err := gorm.Open("mysql", config.FormatDSN()) 34 | if err != nil { 35 | panic("failed to connect database") 36 | } 37 | Mgr = &manager{db: db} 38 | } 39 | -------------------------------------------------------------------------------- /pkg/data/meetings.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "time" 6 | ) 7 | 8 | //meetings: id, name, description, frequency_per_month, minutes, start_date, end_date 9 | //meeting_users: meeting_id, user_id 10 | 11 | type Meeting struct { 12 | ID string `gorm:"primary_key"` 13 | Name string 14 | Description string `sql:"type:text"` 15 | Attendees uint8 16 | Mins uint8 17 | FrequencyPerMonth uint8 18 | StartDate time.Time 19 | EndDate time.Time 20 | CreatedAt time.Time 21 | UpdatedAt time.Time 22 | } 23 | 24 | func (mgr *manager) CreateMeetings(meetings []*Meeting) { 25 | errors := mgr.db.Create(meetings).GetErrors() 26 | if len(errors) > 0 { 27 | log.Fatalf("Error creating meeting: %v", errors[0]) 28 | } 29 | } 30 | 31 | func (mgr *manager) CreateMeeting(meeting *Meeting) { 32 | errors := mgr.db.Set("gorm:insert_option", "ON DUPLICATE KEY UPDATE id=id").Create(meeting).GetErrors() 33 | if len(errors) > 0 { 34 | log.Fatalf("Error creating meeting: %v", errors[0]) 35 | } 36 | } 37 | 38 | func (mgr *manager) ClearMeetings() { 39 | errors := mgr.db.Delete(Meeting{}).GetErrors() 40 | if len(errors) > 0 { 41 | log.Fatalf("Error clearing meetings: %v", errors[0]) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/data/setup.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "github.com/chasdevs/meetrics/pkg/conf" 6 | "github.com/go-sql-driver/mysql" 7 | "github.com/jinzhu/gorm" 8 | "log" 9 | ) 10 | 11 | func SetupDb() { 12 | // Get config 13 | setupConfig := conf.MysqlRootConfig() 14 | 15 | // Get connection 16 | db := getDb(&setupConfig) 17 | 18 | config := conf.MysqlConfig() 19 | 20 | // Make database 21 | sql := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %v;", config.DBName) 22 | db.Exec(sql) 23 | 24 | // Create user 25 | sql = fmt.Sprintf("CREATE USER IF NOT EXISTS '%v'@'%%' IDENTIFIED BY '%v';", config.User, config.Passwd) 26 | db.Exec(sql) 27 | 28 | sql = fmt.Sprintf("GRANT ALL ON %v.* TO '%v'@'%%';", config.DBName, config.User) 29 | db.Exec(sql) 30 | } 31 | 32 | func TeardownDb() { 33 | 34 | rootConfig := conf.MysqlRootConfig() 35 | db := getDb(&rootConfig) 36 | 37 | // Make database 38 | //db.Exec("DROP USER IF EXISTS meetrics;") 39 | db.Exec("DROP DATABASE IF EXISTS meetrics;") 40 | 41 | } 42 | 43 | func Migrate() { 44 | config := conf.MysqlConfig() 45 | db := getDb(&config) 46 | db.DropTableIfExists(&UserMeetingMins{}, &User{}, &Meeting{}) 47 | db.Set("gorm:table_options", "ENGINE=InnoDB").AutoMigrate(&User{}, &UserMeetingMins{}, &Meeting{}) 48 | db.Model(&UserMeetingMins{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT") 49 | } 50 | 51 | func getDb(config *mysql.Config) *gorm.DB { 52 | db, err := gorm.Open("mysql", config.FormatDSN()) 53 | if err != nil { 54 | log.Fatalf("Could not connect to database: %v", err) 55 | } 56 | 57 | return db 58 | } 59 | -------------------------------------------------------------------------------- /pkg/data/userMeetingMins.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "time" 6 | ) 7 | 8 | // Data 9 | 10 | func (mgr *manager) CreateUserMeetingMins(date time.Time, user User, meetingMins map[string]uint) { 11 | ormObj := UserMeetingMins{ 12 | Date: date, 13 | UserID: user.ID, 14 | Mins0: meetingMins["mins0"], 15 | Mins1: meetingMins["mins1"], 16 | Mins2Plus: meetingMins["mins2Plus"], 17 | } 18 | 19 | errors := mgr.db.Create(&ormObj).GetErrors() 20 | if len(errors) > 0 { 21 | log.Error("Error adding user meeting mins: %v", errors[0]) 22 | } 23 | } 24 | 25 | func (mgr *manager) ClearUserMeetingMins() { 26 | errors := mgr.db.Delete(UserMeetingMins{}).GetErrors() 27 | if len(errors) > 0 { 28 | log.Fatalf("Error clearing user meeting mins: %v", errors[0]) 29 | } 30 | } 31 | 32 | type UserMeetingMins struct { 33 | Date time.Time `gorm:"primary_key" sql:"type:date"` 34 | UserID uint `gorm:"primary_key" sql:"type:int unsigned"` 35 | Mins0 uint 36 | Mins1 uint 37 | Mins2Plus uint 38 | CreatedAt time.Time 39 | } 40 | -------------------------------------------------------------------------------- /pkg/data/users.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | ID uint 10 | Email string `gorm:"not null;unique"` 11 | Name string 12 | Department string 13 | CreatedAt time.Time 14 | UpdatedAt time.Time 15 | } 16 | 17 | func (mgr *manager) AddAllUsers(users []*User) { 18 | for _, user := range users { 19 | errors := mgr.db.Create(user).GetErrors() 20 | if len(errors) > 0 { 21 | log.Fatalf("Error adding user: %v", errors[0]) 22 | } 23 | } 24 | } 25 | 26 | func (mgr *manager) GetAllUsers() []User { 27 | var users []User 28 | mgr.db.Find(&users) 29 | return users 30 | } 31 | 32 | func (mgr *manager) GetUserByEmail(email string) User { 33 | var user User 34 | mgr.db.First(&user, "email = ?", email) 35 | return user 36 | } 37 | 38 | func (mgr *manager) GetUserById(id int) User { 39 | var user User 40 | mgr.db.First(&user, "id = ?", id) 41 | return user 42 | } 43 | -------------------------------------------------------------------------------- /pkg/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "sync" 7 | "time" 8 | 9 | _ "github.com/jinzhu/gorm/dialects/mysql" 10 | "google.golang.org/api/calendar/v3" 11 | 12 | "github.com/chasdevs/meetrics/pkg/apis" 13 | "github.com/chasdevs/meetrics/pkg/data" 14 | "github.com/chasdevs/meetrics/pkg/util" 15 | log "github.com/sirupsen/logrus" 16 | "github.com/spf13/cast" 17 | "math/rand" 18 | ) 19 | 20 | // Types 21 | 22 | type UserEvent struct { 23 | user *data.User 24 | event *calendar.Event 25 | } 26 | 27 | // Template: Mon Jan 2 15:04:05 -0700 MST 2006 28 | const EventDateTimeFormat = "2006-01-02T15:04:05-07:00" 29 | 30 | // Private Functions 31 | 32 | func CompileMetrics(date time.Time) { 33 | eventChans := compileMetricsForAllUsersAndGetEventChannels(date) 34 | eventMap := generateEventMap(eventChans) 35 | computeEventMetrics(eventMap) 36 | } 37 | 38 | func compileMetricsForAllUsersAndGetEventChannels(date time.Time) []<-chan UserEvent { 39 | 40 | // All Users 41 | users := data.Mgr.GetAllUsers() 42 | 43 | // Limit the number of requests we make at once 44 | concurrency := 20 45 | sem := make(chan bool, concurrency) 46 | eventChans := make([]<-chan UserEvent, len(users)) 47 | 48 | for i, user := range users { 49 | eventChan := make(chan UserEvent, 10000) 50 | sem <- true // Start reserving space in the semaphore. 51 | go compileMetricsForUserWithSemaphore(date, user, eventChan, sem) 52 | eventChans[i] = eventChan 53 | } 54 | 55 | // Block here until all jobs are finished 56 | for i := 0; i < cap(sem); i++ { 57 | sem <- true // Attempt to send to semaphore; will only work when the async jobs have popped from it. 58 | } 59 | 60 | return eventChans 61 | } 62 | 63 | func generateEventMap(eventChans []<-chan UserEvent) map[string]*calendar.Event { 64 | 65 | eventMap := make(map[string]*calendar.Event) 66 | for userEvent := range merge(eventChans...) { 67 | 68 | event := userEvent.event 69 | 70 | // Store the event in the map 71 | if _, ok := eventMap[event.Id]; !ok { 72 | eventMap[event.Id] = event 73 | } 74 | 75 | } 76 | 77 | return eventMap 78 | } 79 | 80 | func compileMetricsForUserWithSemaphore(date time.Time, user data.User, eventChan chan UserEvent, sem chan bool) { 81 | defer func() { 82 | <-sem 83 | }() 84 | CompileMetricsForUser(date, user, eventChan) 85 | } 86 | 87 | func CompileMetricsForUser(date time.Time, user data.User, eventChan chan<- UserEvent) { 88 | ctxLog := log.WithFields(log.Fields{"email": user.Email, "date": date.Format("2006-01-02")}) 89 | defer close(eventChan) 90 | 91 | ctxLog.Debug("Compiling events for user.") 92 | 93 | //events := getDummyEventsForUser(date, user) 94 | events := getEventsForUser(date, user) 95 | ctxLog.WithField("numEvents", len(events)).Debug("Got Events.") 96 | 97 | meetingMins := map[string]uint{ 98 | "mins0": 0, 99 | "mins1": 0, 100 | "mins2": 0, 101 | "mins3Plus": 0, 102 | } 103 | 104 | for _, event := range events { 105 | 106 | // filter unwanted events 107 | if !shouldProcessEvent(event) { 108 | continue 109 | } 110 | 111 | // process event 112 | attendees := numAttendees(event) 113 | mins := getEventLengthMins(event) 114 | 115 | ctxLog.WithFields(log.Fields{ 116 | "id": event.Id, 117 | "summary": event.Summary, 118 | "attendees": attendees, 119 | "mins": mins, 120 | }).Debug("Event Meta.") 121 | 122 | // store metrics 123 | switch { 124 | case attendees == 0: 125 | meetingMins["mins0"] += mins 126 | case attendees == 2: 127 | meetingMins["mins1"] += mins 128 | case attendees > 2: 129 | meetingMins["mins2Plus"] += mins 130 | } 131 | 132 | // send event to channel 133 | if shouldSaveEvent(event) { 134 | eventChan <- UserEvent{&user, event} 135 | } 136 | 137 | } 138 | 139 | // Store in database 140 | data.Mgr.CreateUserMeetingMins(date, user, meetingMins) 141 | 142 | ctxLog.Debugf("Finished compiling metrics for user. Map: %v", meetingMins) 143 | 144 | } 145 | 146 | func shouldProcessEvent(event *calendar.Event) bool { 147 | 148 | isCancelled := event.Status == "cancelled" 149 | hasStartAndEnd := event.Start != nil && event.End != nil && event.Start.DateTime != "" && event.End.DateTime != "" 150 | belongsToRecurringEvent := event.RecurringEventId != "" 151 | 152 | if isCancelled || !hasStartAndEnd || belongsToRecurringEvent { 153 | return false 154 | } 155 | 156 | var maxHrs uint = 6 157 | maxMins := maxHrs * 60 158 | isTooLong := getEventLengthMins(event) > maxMins 159 | 160 | return !isTooLong 161 | } 162 | 163 | func shouldSaveEvent(event *calendar.Event) bool { 164 | 165 | isRecurring := len(event.Recurrence) > 0 166 | multipleAttendees := numAttendees(event) > 2 167 | isRootEvent := !regexp.MustCompile(`(\w+)_\w+`).MatchString(event.Id) // ID is not one of recurring event like "2389fhdicvn_R20170310T200000" 168 | 169 | return isRecurring && isRootEvent && multipleAttendees 170 | } 171 | 172 | func numAttendees(event *calendar.Event) uint8 { 173 | var num uint8 = 0 174 | 175 | for _, attendee := range event.Attendees { 176 | if !isRoom(attendee) && attendee.ResponseStatus == "accepted" { 177 | num++ 178 | } 179 | } 180 | 181 | return num 182 | } 183 | 184 | func isRoom(attendee *calendar.EventAttendee) bool { 185 | rooms := map[string]int{ 186 | "Green Conference Room": 0, 187 | "Zen Conference Room": 0, 188 | "Phone Room- Airstream (Interior) (2 seats)": 2, 189 | } 190 | 191 | _, ok := rooms[attendee.DisplayName] 192 | 193 | isResource := attendee.Resource 194 | 195 | return ok || isResource 196 | } 197 | 198 | func getEventsForUser(date time.Time, user data.User) []*calendar.Event { 199 | 200 | ctxLog := log.WithFields(log.Fields{ 201 | "email": user.Email, 202 | "date": date.Format("2006-01-02"), 203 | }) 204 | 205 | // Form time range 206 | timeMin := date.Format(time.RFC3339) 207 | timeMax := date.AddDate(0, 0, 1).Format(time.RFC3339) 208 | 209 | ctxLog.WithFields(log.Fields{ 210 | "timeMax": timeMax, 211 | "timeMin": timeMin, 212 | }).Debug("Querying user calendar for Events") 213 | 214 | calendarApi := apis.Calendar(user.Email) 215 | eventList, err := calendarApi.Events.List(user.Email).TimeMin(timeMin).TimeMax(timeMax).Do() 216 | if err != nil { 217 | ctxLog.Error("Could not fetch events for user.") 218 | return []*calendar.Event{} 219 | } 220 | 221 | return eventList.Items 222 | } 223 | 224 | func getDummyEventsForUser(date time.Time, user data.User) []*calendar.Event { 225 | 226 | rand.Seed(time.Now().UnixNano()) 227 | 228 | numEvents := 10 229 | 230 | events := make([]*calendar.Event, numEvents) 231 | 232 | for i := 0; i < numEvents; i++ { 233 | id := cast.ToString(i) 234 | 235 | // randomize data 236 | numAttendees := rand.Intn(4) 237 | lengthMins := rand.Intn(60) 238 | 239 | // create start/end times 240 | hrsUntilBusinessStart := 8 241 | hrsUntilStart := hrsUntilBusinessStart + i 242 | minsUntilEnd := hrsUntilStart*60 + lengthMins 243 | start := date.Add(time.Duration(hrsUntilStart * int(time.Hour))) 244 | end := date.Add(time.Duration(minsUntilEnd * int(time.Minute))) 245 | startString := start.Format(EventDateTimeFormat) 246 | endString := end.Format(EventDateTimeFormat) 247 | 248 | events[i] = &calendar.Event{ 249 | Id: id, 250 | Description: "Dummy Event " + id, 251 | Start: &calendar.EventDateTime{DateTime: startString}, 252 | End: &calendar.EventDateTime{DateTime: endString}, 253 | Attendees: make([]*calendar.EventAttendee, numAttendees), 254 | } 255 | } 256 | 257 | return events 258 | } 259 | 260 | func computeEventMetrics(eventMap map[string]*calendar.Event) { 261 | // Restriction: Only save recurring meetings with 3plus attendees 262 | 263 | log.WithField("len", len(eventMap)).Debug("Created Event Map") 264 | 265 | // For each event in the event map, store the event in the database as a meeting 266 | for eventId, event := range eventMap { 267 | 268 | frequency, err := frequencyFromEvent(event) 269 | if err != nil { 270 | continue 271 | } 272 | 273 | meeting := data.Meeting{ 274 | ID: eventId, 275 | Name: event.Summary, 276 | Description: util.StripCtlAndExtFromUTF8(event.Description), 277 | Attendees: numAttendees(event), 278 | Mins: uint8(getEventLengthMins(event)), 279 | FrequencyPerMonth: frequency, 280 | StartDate: parseEventDateTime(event.Start), 281 | EndDate: parseEventDateTime(event.End), 282 | } 283 | 284 | data.Mgr.CreateMeeting(&meeting) 285 | } 286 | 287 | } 288 | 289 | func frequencyFromEvent(event *calendar.Event) (uint8, error) { 290 | 291 | // https://regex-golang.appspot.com/assets/html/index.html 292 | regex := regexp.MustCompile(`FREQ=(\w+)(.*INTERVAL(\d+))?`) 293 | 294 | var result []string 295 | for _, recurrence := range event.Recurrence { 296 | result = regex.FindStringSubmatch(recurrence) 297 | if len(result) > 0 { 298 | break 299 | } 300 | } 301 | 302 | if len(result) < 1 { 303 | log.WithFields(log.Fields{ 304 | "result": result, 305 | "event": event, 306 | }).Error("Could not match regex for frequency in event.") 307 | return 0, errors.New("could not match regex from event") 308 | } 309 | 310 | freq := result[1] 311 | interval := result[3] 312 | 313 | switch freq + interval { 314 | case "WEEKLY": 315 | return 4, nil 316 | case "WEEKLY2": 317 | return 2, nil 318 | case "MONTHLY": 319 | return 1, nil 320 | default: 321 | log.WithFields(log.Fields{ 322 | "summary": event.Summary, 323 | "recurrence": event.Recurrence, 324 | "freq + interval": freq + interval, 325 | }).Error("Could not parse frequency for event.") 326 | return 0, errors.New("could not parse frequency from event") 327 | } 328 | 329 | } 330 | 331 | // UTIL 332 | 333 | func beginningOfYesterday() time.Time { 334 | return beginningOfDay(1) 335 | } 336 | 337 | func beginningOfDay(daysAgo int) time.Time { 338 | now := time.Now() 339 | year, month, yesterday := now.AddDate(0, 0, -1*daysAgo).Date() 340 | return time.Date(year, month, yesterday, 0, 0, 0, 0, now.Location()) 341 | } 342 | 343 | func getEventLengthMins(event *calendar.Event) uint { 344 | end := parseEventDateTime(event.End) 345 | start := parseEventDateTime(event.Start) 346 | return uint(end.Sub(start).Minutes()) 347 | } 348 | 349 | func parseEventDateTime(e *calendar.EventDateTime) time.Time { 350 | 351 | result, err := time.Parse(EventDateTimeFormat, e.DateTime) 352 | 353 | if err != nil { 354 | log.WithFields(log.Fields{ 355 | "DateTime": e.DateTime, 356 | }).Error("Could not parse EventDateTime.") 357 | panic("killing") 358 | } 359 | 360 | return result 361 | } 362 | 363 | func merge(chans ...<-chan UserEvent) <-chan UserEvent { 364 | var wg sync.WaitGroup 365 | out := make(chan UserEvent) 366 | 367 | // Start an output goroutine for each input channel in chans. output 368 | // copies values from c to out until c is closed, then calls wg.Done. 369 | output := func(c <-chan UserEvent) { 370 | for n := range c { 371 | out <- n 372 | } 373 | wg.Done() 374 | } 375 | 376 | // Start routines for collecting output 377 | wg.Add(len(chans)) 378 | for _, c := range chans { 379 | go output(c) 380 | } 381 | 382 | // Start a goroutine to close out once all the output goroutines are 383 | // done. This must start after the wg.Add call. 384 | go func() { 385 | wg.Wait() 386 | close(out) 387 | }() 388 | return out 389 | } 390 | -------------------------------------------------------------------------------- /pkg/setup/setup.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/data" 5 | ) 6 | 7 | func Migrate() { 8 | data.Migrate() 9 | } 10 | 11 | func Setup() { 12 | data.SetupDb() 13 | } 14 | 15 | func TearDown() { 16 | data.TeardownDb() 17 | } 18 | -------------------------------------------------------------------------------- /pkg/setup/userdepartments/user-departments.csv: -------------------------------------------------------------------------------- 1 | "Testington, Test",test@test.com,Engineering -------------------------------------------------------------------------------- /pkg/setup/userdepartments/userDepartments.go: -------------------------------------------------------------------------------- 1 | package userdepartments 2 | 3 | import ( 4 | "bufio" 5 | "encoding/csv" 6 | . "github.com/chasdevs/meetrics/pkg/data" 7 | "github.com/chasdevs/meetrics/pkg/util" 8 | log "github.com/sirupsen/logrus" 9 | "io" 10 | "os" 11 | "path" 12 | ) 13 | 14 | func GetUsersFromFile() []*User { 15 | 16 | log.Infoln("Getting users and departments from csv file.") 17 | 18 | // Open the csv. 19 | filePath := path.Join(util.ThisFilePath(), "user-departments.csv") 20 | csvFile, e := os.Open(filePath) 21 | if csvFile != nil { 22 | defer func() { 23 | if e := csvFile.Close(); e != nil { 24 | panic(e) 25 | } 26 | }() 27 | } 28 | 29 | if e != nil { 30 | panic(e) 31 | } 32 | 33 | // Create a Reader. 34 | reader := csv.NewReader(bufio.NewReader(csvFile)) 35 | //reader. 36 | 37 | // Iterate through the lines and create an array of User objects. 38 | var users []*User 39 | lines := 0 40 | for { 41 | 42 | line, e := reader.Read() 43 | if e == io.EOF { 44 | break 45 | } else if e != nil { 46 | panic(e) 47 | } 48 | 49 | lines++ 50 | users = append(users, &User{ 51 | Name: line[0], 52 | Email: line[1], 53 | Department: line[2], 54 | }) 55 | 56 | } 57 | 58 | return users 59 | } 60 | -------------------------------------------------------------------------------- /pkg/setup/users.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/apis" 5 | "github.com/chasdevs/meetrics/pkg/conf" 6 | "github.com/chasdevs/meetrics/pkg/data" 7 | "github.com/chasdevs/meetrics/pkg/setup/userdepartments" 8 | "log" 9 | ) 10 | 11 | func PopulateUsersFromApi() { 12 | // fetch all users from the google org and store in users table 13 | // - Filter emails somehow? Blacklist email addresses which are not people? 14 | // - Do not overwrite emails if they already exist (keep the same id) 15 | adminApi := apis.Admin() 16 | res, err := adminApi.Users.List().Domain(conf.GetString("google.domain")).Do() 17 | if err != nil { 18 | log.Fatalf("Error fetching users for organization: %v", err) 19 | } 20 | 21 | users := make([]*data.User, len(res.Users)) 22 | for i, user := range res.Users { 23 | users[i] = &data.User{Email: user.PrimaryEmail, Name: user.Name.FullName} 24 | } 25 | 26 | addUsers(users) 27 | } 28 | 29 | func PopulateUsersFromCsv() { 30 | users := userdepartments.GetUsersFromFile() 31 | addUsers(users) 32 | } 33 | 34 | func addUsers(users []*data.User) { 35 | data.Init() 36 | data.Mgr.AddAllUsers(users) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "path" 5 | "runtime" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func BeginningOfYesterday() time.Time { 11 | return BeginningOfDay(1) 12 | } 13 | 14 | func BeginningOfDay(daysAgo int) time.Time { 15 | now := time.Now() 16 | year, month, yesterday := now.AddDate(0, 0, -1*daysAgo).Date() 17 | return time.Date(year, month, yesterday, 0, 0, 0, 0, now.Location()) 18 | } 19 | 20 | func IsWeekday(date time.Time) bool { 21 | return date.Weekday() > 0 && date.Weekday() < 6 22 | } 23 | 24 | // https://rosettacode.org/wiki/Strip_control_codes_and_extended_characters_from_a_string#Go 25 | func StripCtlAndExtFromUTF8(str string) string { 26 | return strings.Map(func(r rune) rune { 27 | if r >= 32 && r < 127 { 28 | return r 29 | } 30 | return -1 31 | }, str) 32 | } 33 | 34 | // Filepath 35 | func ThisFilePath() string { 36 | _, filename, _, _ := runtime.Caller(1) 37 | filename = path.Join(filename, "..") 38 | return filename 39 | } 40 | -------------------------------------------------------------------------------- /scratch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/chasdevs/meetrics/pkg/apis" 5 | "github.com/chasdevs/meetrics/pkg/conf" 6 | "github.com/chasdevs/meetrics/pkg/data" 7 | "github.com/chasdevs/meetrics/pkg/metrics" 8 | log "github.com/sirupsen/logrus" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func init() { 14 | log.SetLevel(log.DebugLevel) 15 | data.Init() 16 | } 17 | 18 | func main() { 19 | eventChan := make(chan<- metrics.UserEvent) 20 | user := data.Mgr.GetUserById(82) 21 | metrics.CompileMetricsForUser(time.Date(2017, 11, 7, 0, 0, 0, 0, time.UTC), user, eventChan) 22 | } 23 | 24 | func searchUsers(name string) { 25 | adminApi := apis.Admin() 26 | res, err := adminApi.Users.List().Query("name:" + name).Domain(conf.GetString("google.domain")).Do() 27 | if err != nil { 28 | log.Fatalf("Error fetching users for organization: %v", err) 29 | } 30 | 31 | emails := make([]string, len(res.Users)) 32 | for i, user := range res.Users { 33 | emails[i] = user.PrimaryEmail 34 | } 35 | log.Info("Matching emails: " + strings.Join(emails, ",")) 36 | 37 | } 38 | --------------------------------------------------------------------------------