mirror of
https://github.com/plausible/analytics.git
synced 2024-12-28 12:01:39 +03:00
Merge branch 'master' into dont-leak-internal-errors
This commit is contained in:
commit
e9de698242
@ -31,6 +31,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Replace `CLICKHOUSE_MAX_BUFFER_SIZE` with `CLICKHOUSE_MAX_BUFFER_SIZE_BYTES`
|
||||
|
||||
### Fixed
|
||||
- Stop returning custom events in goal breakdown with a pageview goal filter and vice versa
|
||||
- Only return `(none)` values in custom property breakdown for the first page (pagination) of results
|
||||
- Fixed weekly/monthly e-mail report [rendering issues](https://github.com/plausible/analytics/issues/284)
|
||||
- Fix [broken interval selection](https://github.com/plausible/analytics/issues/2982) in the all time view plausible/analytics#3110
|
||||
|
@ -16,10 +16,12 @@ if (container) {
|
||||
domain: container.dataset.domain,
|
||||
offset: container.dataset.offset,
|
||||
hasGoals: container.dataset.hasGoals === 'true',
|
||||
conversionsEnabled: container.dataset.conversionsEnabled === 'true',
|
||||
funnelsEnabled: container.dataset.funnelsEnabled === 'true',
|
||||
propsEnabled: container.dataset.propsEnabled === 'true',
|
||||
hasProps: container.dataset.hasProps === 'true',
|
||||
funnelsAvailable: container.dataset.funnelsAvailable === 'true',
|
||||
propsAvailable: container.dataset.propsAvailable === 'true',
|
||||
conversionsOptedOut: container.dataset.conversionsOptedOut === 'true',
|
||||
funnelsOptedOut: container.dataset.funnelsOptedOut === 'true',
|
||||
propsOptedOut: container.dataset.propsOptedOut === 'true',
|
||||
funnels: JSON.parse(container.dataset.funnels),
|
||||
statsBegin: container.dataset.statsBegin,
|
||||
nativeStatsBegin: container.dataset.nativeStatsBegin,
|
||||
|
@ -2,7 +2,7 @@ import React from "react"
|
||||
import { sectionTitles } from "../stats/behaviours"
|
||||
import * as api from '../api'
|
||||
|
||||
export function FeatureSetupNotice({ site, feature, shortFeatureName, title, info, settingsLink, onHideAction }) {
|
||||
export function FeatureSetupNotice({ site, feature, title, info, callToAction, onHideAction }) {
|
||||
const sectionTitle = sectionTitles[feature]
|
||||
|
||||
const requestHideSection = () => {
|
||||
@ -14,10 +14,10 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
|
||||
}
|
||||
}
|
||||
|
||||
function setupButton() {
|
||||
function renderCallToAction() {
|
||||
return (
|
||||
<a href={settingsLink} className="ml-2 sm:ml-4 button px-2 sm:px-4">
|
||||
<p className="flex flex-col justify-center text-xs sm:text-sm">Set up {shortFeatureName}</p>
|
||||
<a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4">
|
||||
<p className="flex flex-col justify-center text-xs sm:text-sm">{callToAction.action}</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="ml-2 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
||||
</svg>
|
||||
@ -25,7 +25,7 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
|
||||
)
|
||||
}
|
||||
|
||||
function hideButton() {
|
||||
function renderHideButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={requestHideSection}
|
||||
@ -47,8 +47,8 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
|
||||
</div>
|
||||
|
||||
<div className="text-xs sm:text-sm flex my-6 justify-center">
|
||||
{hideButton()}
|
||||
{setupButton()}
|
||||
{renderHideButton()}
|
||||
{renderCallToAction()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -43,7 +43,7 @@ export default function Behaviours(props) {
|
||||
const [mode, setMode] = useState(defaultMode())
|
||||
|
||||
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name))
|
||||
const [selectedFunnel, setSelectedFunnel] = useState(storage.getItem(funnelKey))
|
||||
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
|
||||
|
||||
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
|
||||
|
||||
@ -83,8 +83,22 @@ export default function Behaviours(props) {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSelectedFunnel() {
|
||||
const stored = storage.getItem(funnelKey)
|
||||
const storedExists = stored && site.funnels.some((f) => f.name === stored)
|
||||
|
||||
if (storedExists) {
|
||||
return stored
|
||||
} else if (site.funnels.length > 0) {
|
||||
const firstAvailable = site.funnels[0].name
|
||||
|
||||
storage.setItem(funnelKey, firstAvailable)
|
||||
return firstAvailable
|
||||
}
|
||||
}
|
||||
|
||||
function hasFunnels() {
|
||||
return site.funnels.length > 0
|
||||
return site.funnels.length > 0 && site.funnelsAvailable
|
||||
}
|
||||
|
||||
function tabFunnelPicker() {
|
||||
@ -164,10 +178,12 @@ export default function Behaviours(props) {
|
||||
<FeatureSetupNotice
|
||||
site={site}
|
||||
feature={CONVERSIONS}
|
||||
shortFeatureName={'goals'}
|
||||
title={'Measure how often visitors complete specific actions'}
|
||||
info={'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'}
|
||||
settingsLink={`/${encodeURIComponent(site.domain)}/settings/goals`}
|
||||
callToAction={{
|
||||
action: "Set up goals",
|
||||
link: `/${encodeURIComponent(site.domain)}/settings/goals`
|
||||
}}
|
||||
onHideAction={onHideAction(CONVERSIONS)}
|
||||
/>
|
||||
)
|
||||
@ -179,18 +195,25 @@ export default function Behaviours(props) {
|
||||
if (Funnel === null) {
|
||||
return featureUnavailable()
|
||||
}
|
||||
else if (Funnel && selectedFunnel) {
|
||||
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
|
||||
return <Funnel site={site} query={query} funnelName={selectedFunnel} />
|
||||
}
|
||||
else if (Funnel && adminAccess) {
|
||||
let callToAction
|
||||
|
||||
if (site.funnelsAvailable) {
|
||||
callToAction = {action: 'Set up funnels', link: `/${encodeURIComponent(site.domain)}/settings/funnels`}
|
||||
} else {
|
||||
callToAction = {action: 'Upgrade', link: '/billing/choose-plan'}
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureSetupNotice
|
||||
site={site}
|
||||
feature={FUNNELS}
|
||||
shortFeatureName={'funnels'}
|
||||
title={'Follow the visitor journey from entry to conversion'}
|
||||
info={'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'}
|
||||
settingsLink={`/${encodeURIComponent(site.domain)}/settings/funnels`}
|
||||
callToAction={callToAction}
|
||||
onHideAction={onHideAction(FUNNELS)}
|
||||
/>
|
||||
)
|
||||
@ -199,17 +222,24 @@ export default function Behaviours(props) {
|
||||
}
|
||||
|
||||
function renderProps() {
|
||||
if (site.hasProps) {
|
||||
if (site.hasProps && site.propsAvailable) {
|
||||
return <Properties site={site} query={query} />
|
||||
} else if (adminAccess) {
|
||||
let callToAction
|
||||
|
||||
if (site.propsAvailable) {
|
||||
callToAction = {action: 'Set up props', link: `/${encodeURIComponent(site.domain)}/settings/properties`}
|
||||
} else {
|
||||
callToAction = {action: 'Upgrade', link: '/billing/choose-plan'}
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureSetupNotice
|
||||
site={site}
|
||||
feature={PROPS}
|
||||
shortFeatureName={'props'}
|
||||
title={'No custom properties found'}
|
||||
title={'Send custom data to create your own metrics'}
|
||||
info={'You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don\'t track automatically.'}
|
||||
settingsLink={`/${encodeURIComponent(site.domain)}/settings/properties`}
|
||||
callToAction={callToAction}
|
||||
onHideAction={onHideAction(PROPS)}
|
||||
/>
|
||||
)
|
||||
@ -261,15 +291,20 @@ export default function Behaviours(props) {
|
||||
function getEnabledModes() {
|
||||
let enabledModes = []
|
||||
|
||||
if (site.conversionsEnabled) {
|
||||
enabledModes.push(CONVERSIONS)
|
||||
}
|
||||
if (site.propsEnabled) {
|
||||
enabledModes.push(PROPS)
|
||||
}
|
||||
if (site.funnelsEnabled && !isRealtime()) {
|
||||
enabledModes.push(FUNNELS)
|
||||
for (const feature of Object.keys(sectionTitles)) {
|
||||
const isOptedOut = site[feature + 'OptedOut']
|
||||
const isAvailable = site[feature + 'Available'] !== false
|
||||
|
||||
// If the feature is not supported by the site owner's subscription,
|
||||
// it only makes sense to display the feature tab to the owner itself
|
||||
// as only they can upgrade to make the feature available.
|
||||
const callToActionIsMissing = !isAvailable && currentUserRole !== 'owner'
|
||||
|
||||
if (!isOptedOut && !callToActionIsMissing) {
|
||||
enabledModes.push(feature)
|
||||
}
|
||||
}
|
||||
|
||||
return enabledModes
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ class Modal extends React.Component {
|
||||
componentDidMount() {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.height = '100vh';
|
||||
document.addEventListener("click", this.handleClickOutside);
|
||||
document.addEventListener("mousedown", this.handleClickOutside);
|
||||
document.addEventListener("keyup", this.handleKeyup);
|
||||
window.addEventListener('resize', this.handleResize, false);
|
||||
this.handleResize();
|
||||
@ -33,7 +33,7 @@ class Modal extends React.Component {
|
||||
componentWillUnmount() {
|
||||
document.body.style.overflow = null;
|
||||
document.body.style.height = null;
|
||||
document.removeEventListener("click", this.handleClickOutside);
|
||||
document.removeEventListener("mousedown", this.handleClickOutside);
|
||||
document.removeEventListener("keyup", this.handleKeyup);
|
||||
window.removeEventListener('resize', this.handleResize, false);
|
||||
}
|
||||
|
@ -56,6 +56,13 @@ defmodule Plausible.Billing.Feature do
|
||||
"""
|
||||
@callback enabled?(Plausible.Site.t()) :: boolean()
|
||||
|
||||
@doc """
|
||||
Returns whether the site explicitly opted out of the feature. This function
|
||||
is different from enabled/1, because enabled/1 returns false when the site
|
||||
owner does not have access to the feature.
|
||||
"""
|
||||
@callback opted_out?(Plausible.Site.t()) :: boolean()
|
||||
|
||||
@doc """
|
||||
Checks whether the site owner or the user plan includes the given feature.
|
||||
"""
|
||||
@ -101,12 +108,12 @@ defmodule Plausible.Billing.Feature do
|
||||
@impl true
|
||||
def enabled?(%Plausible.Site{} = site) do
|
||||
site = Plausible.Repo.preload(site, :owner)
|
||||
check_availability(site.owner) == :ok && !opted_out?(site)
|
||||
end
|
||||
|
||||
cond do
|
||||
check_availability(site.owner) !== :ok -> false
|
||||
is_nil(toggle_field()) -> true
|
||||
true -> Map.fetch!(site, toggle_field())
|
||||
end
|
||||
@impl true
|
||||
def opted_out?(%Plausible.Site{} = site) do
|
||||
if is_nil(toggle_field()), do: false, else: !Map.fetch!(site, toggle_field())
|
||||
end
|
||||
|
||||
@impl true
|
||||
@ -120,18 +127,23 @@ defmodule Plausible.Billing.Feature do
|
||||
|
||||
@impl true
|
||||
def toggle(%Plausible.Site{} = site, opts \\ []) do
|
||||
with key when not is_nil(key) <- toggle_field(),
|
||||
site <- Plausible.Repo.preload(site, :owner),
|
||||
:ok <- check_availability(site.owner) do
|
||||
override = Keyword.get(opts, :override)
|
||||
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
|
||||
if toggle_field(), do: do_toggle(site, opts), else: :ok
|
||||
end
|
||||
|
||||
site
|
||||
|> Ecto.Changeset.change(%{toggle_field() => toggle})
|
||||
|> Plausible.Repo.update()
|
||||
else
|
||||
nil = _feature_not_togglable -> :ok
|
||||
{:error, :upgrade_required} -> {:error, :upgrade_required}
|
||||
defp do_toggle(%Plausible.Site{} = site, opts) do
|
||||
site = Plausible.Repo.preload(site, :owner)
|
||||
override = Keyword.get(opts, :override)
|
||||
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
|
||||
availability = if toggle, do: check_availability(site.owner), else: :ok
|
||||
|
||||
case availability do
|
||||
:ok ->
|
||||
site
|
||||
|> Ecto.Changeset.change(%{toggle_field() => toggle})
|
||||
|> Plausible.Repo.update()
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -68,6 +68,7 @@ defmodule Plausible.Site do
|
||||
|> cast(attrs, [:domain, :timezone])
|
||||
|> clean_domain()
|
||||
|> validate_required([:domain, :timezone])
|
||||
|> validate_timezone()
|
||||
|> validate_domain_format()
|
||||
|> validate_domain_reserved_characters()
|
||||
|> unique_constraint(:domain,
|
||||
@ -265,4 +266,14 @@ defmodule Plausible.Site do
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_timezone(changeset) do
|
||||
tz = get_field(changeset, :timezone)
|
||||
|
||||
if Timex.is_valid_timezone?(tz) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :timezone, "is invalid")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -60,18 +60,24 @@ defmodule Plausible.Stats.Base do
|
||||
q =
|
||||
case query.filters["event:goal"] do
|
||||
{:is, {:page, path}} ->
|
||||
from(e in q, where: e.pathname == ^path)
|
||||
from(e in q, where: e.pathname == ^path and e.name == "pageview")
|
||||
|
||||
{:matches, {:page, expr}} ->
|
||||
regex = page_regex(expr)
|
||||
from(e in q, where: fragment("match(?, ?)", e.pathname, ^regex))
|
||||
|
||||
from(e in q,
|
||||
where: fragment("match(?, ?)", e.pathname, ^regex) and e.name == "pageview"
|
||||
)
|
||||
|
||||
{:is, {:event, event}} ->
|
||||
from(e in q, where: e.name == ^event)
|
||||
|
||||
{:member, clauses} ->
|
||||
{events, pages} = split_goals(clauses)
|
||||
from(e in q, where: e.pathname in ^pages or e.name in ^events)
|
||||
|
||||
from(e in q,
|
||||
where: (e.pathname in ^pages and e.name == "pageview") or e.name in ^events
|
||||
)
|
||||
|
||||
{:matches_member, clauses} ->
|
||||
{events, pages} = split_goals(clauses, &page_regex/1)
|
||||
@ -85,7 +91,10 @@ defmodule Plausible.Stats.Base do
|
||||
|
||||
page_clause =
|
||||
if Enum.any?(pages) do
|
||||
dynamic([x], fragment("multiMatchAny(?, ?)", x.pathname, ^pages))
|
||||
dynamic(
|
||||
[x],
|
||||
fragment("multiMatchAny(?, ?)", x.pathname, ^pages) and x.name == "pageview"
|
||||
)
|
||||
else
|
||||
dynamic([x], false)
|
||||
end
|
||||
@ -94,31 +103,6 @@ defmodule Plausible.Stats.Base do
|
||||
|
||||
from(e in q, where: ^where_clause)
|
||||
|
||||
{:not_matches_member, clauses} ->
|
||||
{events, pages} = split_goals(clauses, &page_regex/1)
|
||||
|
||||
event_clause =
|
||||
if Enum.any?(events) do
|
||||
dynamic([x], fragment("multiMatchAny(?, ?)", x.name, ^events))
|
||||
else
|
||||
dynamic([x], false)
|
||||
end
|
||||
|
||||
page_clause =
|
||||
if Enum.any?(pages) do
|
||||
dynamic([x], fragment("multiMatchAny(?, ?)", x.pathname, ^pages))
|
||||
else
|
||||
dynamic([x], false)
|
||||
end
|
||||
|
||||
where_clause = dynamic([], not (^event_clause or ^page_clause))
|
||||
|
||||
from(e in q, where: ^where_clause)
|
||||
|
||||
{:not_member, clauses} ->
|
||||
{events, pages} = split_goals(clauses)
|
||||
from(e in q, where: e.pathname not in ^pages and e.name not in ^events)
|
||||
|
||||
nil ->
|
||||
q
|
||||
end
|
||||
|
@ -57,7 +57,7 @@ defmodule Plausible.Stats.Breakdown do
|
||||
"notEmpty(multiMatchAllIndices(?, ?) as indices)",
|
||||
e.pathname,
|
||||
^page_regexes
|
||||
),
|
||||
) and e.name == "pageview",
|
||||
group_by: fragment("index"),
|
||||
select: %{
|
||||
index: fragment("arrayJoin(indices) as index"),
|
||||
|
@ -368,6 +368,7 @@ defmodule Plausible.Stats.Imported do
|
||||
|
||||
defp select_imported_metrics(q, [:pageviews | rest]) do
|
||||
q
|
||||
|> where([i], i.pageviews > 0)
|
||||
|> select_merge([i], %{pageviews: sum(i.pageviews)})
|
||||
|> select_imported_metrics(rest)
|
||||
end
|
||||
|
@ -1214,21 +1214,30 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
prop_names = Plausible.Stats.CustomProps.fetch_prop_names(site, query)
|
||||
|
||||
values =
|
||||
prop_names
|
||||
|> Enum.map(fn prop_key ->
|
||||
breakdown_custom_prop_values(site, Map.put(params, "prop_key", prop_key))
|
||||
|> Enum.map(&Map.put(&1, :property, prop_key))
|
||||
|> transform_keys(%{:name => :value})
|
||||
end)
|
||||
|> Enum.concat()
|
||||
prop_names =
|
||||
if Plausible.Billing.Feature.Props.enabled?(site) do
|
||||
prop_names
|
||||
else
|
||||
prop_names |> Enum.filter(&(&1 in Plausible.Props.internal_keys()))
|
||||
end
|
||||
|
||||
percent_or_cr =
|
||||
if query.filters["event:goal"],
|
||||
do: :conversion_rate,
|
||||
else: :percentage
|
||||
if not Enum.empty?(prop_names) do
|
||||
values =
|
||||
prop_names
|
||||
|> Enum.map(fn prop_key ->
|
||||
breakdown_custom_prop_values(site, Map.put(params, "prop_key", prop_key))
|
||||
|> Enum.map(&Map.put(&1, :property, prop_key))
|
||||
|> transform_keys(%{:name => :value})
|
||||
end)
|
||||
|> Enum.concat()
|
||||
|
||||
to_csv(values, [:property, :value, :visitors, :events, percent_or_cr])
|
||||
percent_or_cr =
|
||||
if query.filters["event:goal"],
|
||||
do: :conversion_rate,
|
||||
else: :percentage
|
||||
|
||||
to_csv(values, [:property, :value, :visitors, :events, percent_or_cr])
|
||||
end
|
||||
end
|
||||
|
||||
defp breakdown_custom_prop_values(site, %{"prop_key" => prop_key} = params) do
|
||||
|
@ -159,18 +159,10 @@ defmodule PlausibleWeb.StatsController do
|
||||
~c"operating_systems.csv" => fn -> Api.StatsController.operating_systems(conn, params) end,
|
||||
~c"devices.csv" => fn -> Api.StatsController.screen_sizes(conn, params) end,
|
||||
~c"conversions.csv" => fn -> Api.StatsController.conversions(conn, params) end,
|
||||
~c"referrers.csv" => fn -> Api.StatsController.referrers(conn, params) end
|
||||
~c"referrers.csv" => fn -> Api.StatsController.referrers(conn, params) end,
|
||||
~c"custom_props.csv" => fn -> Api.StatsController.all_custom_prop_values(conn, params) end
|
||||
}
|
||||
|
||||
csvs =
|
||||
if Plausible.Billing.Feature.Props.enabled?(site) do
|
||||
Map.put(csvs, ~c"custom_props.csv", fn ->
|
||||
Api.StatsController.all_custom_prop_values(conn, params)
|
||||
end)
|
||||
else
|
||||
csvs
|
||||
end
|
||||
|
||||
csv_values =
|
||||
Map.values(csvs)
|
||||
|> Plausible.ClickhouseRepo.parallel_tasks()
|
||||
@ -178,6 +170,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
csvs =
|
||||
Map.keys(csvs)
|
||||
|> Enum.zip(csv_values)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|
||||
csvs = [{~c"visitors.csv", visitors} | csvs]
|
||||
|
||||
@ -315,6 +308,8 @@ defmodule PlausibleWeb.StatsController do
|
||||
defp render_shared_link(conn, shared_link) do
|
||||
cond do
|
||||
!shared_link.site.locked ->
|
||||
shared_link = Plausible.Repo.preload(shared_link, site: :owner)
|
||||
|
||||
conn
|
||||
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|
||||
|> delete_resp_header("x-frame-options")
|
||||
|
@ -353,20 +353,26 @@ defmodule PlausibleWeb.Email do
|
||||
})
|
||||
end
|
||||
|
||||
def approaching_accept_traffic_until(user) do
|
||||
def approaching_accept_traffic_until(notification) do
|
||||
base_email()
|
||||
|> to(user.email)
|
||||
|> to(notification.email)
|
||||
|> tag("drop-traffic-warning-first")
|
||||
|> subject("We'll stop counting your stats")
|
||||
|> render("approaching_accept_traffic_until.html", time: "next week")
|
||||
|> render("approaching_accept_traffic_until.html",
|
||||
time: "next week",
|
||||
user: %{email: notification.email, name: notification.name}
|
||||
)
|
||||
end
|
||||
|
||||
def approaching_accept_traffic_until_tomorrow(user) do
|
||||
def approaching_accept_traffic_until_tomorrow(notification) do
|
||||
base_email()
|
||||
|> to(user.email)
|
||||
|> to(notification.email)
|
||||
|> tag("drop-traffic-warning-final")
|
||||
|> subject("A reminder that we'll stop counting your stats tomorrow")
|
||||
|> render("approaching_accept_traffic_until.html", time: "tomorrow")
|
||||
|> render("approaching_accept_traffic_until.html",
|
||||
time: "tomorrow",
|
||||
user: %{email: notification.email, name: notification.name}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -1,8 +1,7 @@
|
||||
You used to have an active account with Plausible Analytics, a simple, lightweight, open source and privacy-first Google Analytics alternative.
|
||||
|
||||
<br /><br />
|
||||
We've noticed that you're still sending us stats so we're writing to inform you that we'll stop accepting stats from your sites <%= @time %>. We're an independent, bootstrapped service and we don't sell your data, so this will reduce our server costs and help keep us sustainable.
|
||||
|
||||
If you'd like to continue counting your site stats in a privacy-friendly way, please
|
||||
<br /><br /> If you'd like to continue counting your site stats in a privacy-friendly way, please
|
||||
<a href={plausible_url()}>login to your Plausible account</a> and start a subscription.
|
||||
|
||||
<br /><br />
|
||||
Do you have any questions or need help with anything? Just reply to this email and we'll gladly help.
|
||||
|
@ -1,49 +0,0 @@
|
||||
<div class="w-full max-w-lg mx-auto mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-8 sm:py-6">
|
||||
<div class="mx-auto flex items-center justify-center rounded-full bg-green-100 h-12 w-12">
|
||||
<svg class="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path></svg>
|
||||
</div>
|
||||
<h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200">
|
||||
Dashboard locked
|
||||
</h3>
|
||||
|
||||
<%= case @conn.assigns[:current_user_role] do %>
|
||||
<% :owner -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is locked because you don't have a valid subscription. We're still counting the stats but your access to the dashboard is restricted. Please subscribe to the appropriate tier with the link below to access the stats again.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<%= link("Manage my subscription", to: "/settings", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
|
||||
</div>
|
||||
<% role when role in [:admin, :viewer] -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is currently locked and cannot be accessed. The site owner <b><%= @site.owner.email %></b> must upgrade their subscription plan in order to
|
||||
unlock the stats.
|
||||
</p>
|
||||
<div class="mt-6 text-sm text-gray-500">
|
||||
<p>Want to pay for this site with the account you're logged in with?</p>
|
||||
<p class="mt-1">Contact <%= @site.owner.email %> and ask them to <%= link("transfer the ownership", class: "text-indigo-500", to: "https://plausible.io/docs/transfer-ownership", rel: "noreferrer") %> of the site over to you</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<%= link("Back to my sites", to: "/sites", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is currently locked and cannot be accessed. You can check back later or contact the site owner to unlock it.
|
||||
</p>
|
||||
</div>
|
||||
<%= if @conn.assigns[:current_user] do %>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<%= link("Back to my sites", to: "/sites", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
80
lib/plausible_web/templates/stats/site_locked.html.heex
Normal file
80
lib/plausible_web/templates/stats/site_locked.html.heex
Normal file
@ -0,0 +1,80 @@
|
||||
<div class="w-full max-w-lg mx-auto mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-8 sm:py-6">
|
||||
<div class="mx-auto flex items-center justify-center rounded-full bg-green-100 h-12 w-12">
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200">
|
||||
Dashboard locked
|
||||
</h3>
|
||||
|
||||
<%= case @conn.assigns[:current_user_role] do %>
|
||||
<% :owner -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is locked because you don't have a valid subscription.
|
||||
Please subscribe to the appropriate tier with the link below to access the stats again.
|
||||
</p>
|
||||
<p class="mt-6 text-sm text-gray-500">
|
||||
You can check the
|
||||
<.styled_link href={Routes.site_path(@conn, :settings_general, @site.domain)}>
|
||||
site settings
|
||||
</.styled_link>
|
||||
and we're still counting the stats but your access to the dashboard is restricted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<.button_link href={Routes.auth_path(@conn, :user_settings)}>
|
||||
Manage my subscription
|
||||
</.button_link>
|
||||
</div>
|
||||
<% role when role in [:admin, :viewer] -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is currently locked and cannot be accessed. The site owner
|
||||
<b><%= @site.owner.email %></b>
|
||||
must upgrade their subscription plan in order to
|
||||
unlock the stats.
|
||||
</p>
|
||||
<div class="mt-6 text-sm text-gray-500">
|
||||
<p>Want to pay for this site with the account you're logged in with?</p>
|
||||
<p class="mt-1">
|
||||
Contact <%= @site.owner.email %> and ask them to
|
||||
<.styled_link href="https://plausible.io/docs/transfer-ownership" new_tab={true}>
|
||||
transfer the ownership
|
||||
</.styled_link>
|
||||
of the site over to you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<.button_link href={Routes.site_path(@conn, :index)}>Back to my sites</.button_link>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
|
||||
<p>
|
||||
This dashboard is currently locked and cannot be accessed.
|
||||
You can check back later or contact the site owner to unlock the stats.
|
||||
</p>
|
||||
</div>
|
||||
<%= if @conn.assigns[:current_user] do %>
|
||||
<div class="mt-6 w-full text-center">
|
||||
<.button_link href={Routes.site_path(@conn, :index)}>Back to my sites</.button_link>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -18,9 +18,11 @@
|
||||
data-domain="<%= @site.domain %>"
|
||||
data-offset="<%= Plausible.Site.tz_offset(@site) %>"
|
||||
data-has-goals="<%= @has_goals %>"
|
||||
data-conversions-enabled="<%= Plausible.Billing.Feature.Goals.enabled?(@site) %>"
|
||||
data-funnels-enabled="<%= Plausible.Billing.Feature.Funnels.enabled?(@site) %>"
|
||||
data-props-enabled="<%= Plausible.Billing.Feature.Props.enabled?(@site) %>"
|
||||
data-conversions-opted-out="<%= Plausible.Billing.Feature.Goals.opted_out?(@site) %>"
|
||||
data-funnels-opted-out="<%= Plausible.Billing.Feature.Funnels.opted_out?(@site) %>"
|
||||
data-props-opted-out="<%= Plausible.Billing.Feature.Props.opted_out?(@site) %>"
|
||||
data-funnels-available="<%= Plausible.Billing.Feature.Funnels.check_availability(@site.owner) == :ok %>"
|
||||
data-props-available="<%= Plausible.Billing.Feature.Props.check_availability(@site.owner) == :ok %>"
|
||||
data-funnels="<%= Jason.encode!(@funnels) %>"
|
||||
data-has-props="<%= @has_props %>"
|
||||
data-logged-in="<%= !!@conn.assigns[:current_user] %>"
|
||||
|
@ -40,7 +40,8 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
deadline: u.accept_traffic_until,
|
||||
site_ids: fragment("array_agg(?.site_id)", sm)
|
||||
site_ids: fragment("array_agg(?.site_id)", sm),
|
||||
name: u.name
|
||||
},
|
||||
group_by: u.id
|
||||
)
|
||||
@ -68,11 +69,11 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
|
||||
end
|
||||
|
||||
defp has_stats?(site_ids, today) do
|
||||
yesterday = Date.add(today, -1)
|
||||
ago_2d = Date.add(today, -2)
|
||||
|
||||
ClickhouseRepo.exists?(
|
||||
from e in "events_v2",
|
||||
where: fragment("toDate(?) >= ?", e.timestamp, ^yesterday),
|
||||
where: fragment("toDate(?) >= ?", e.timestamp, ^ago_2d),
|
||||
where: e.site_id in ^site_ids
|
||||
)
|
||||
end
|
||||
|
@ -0,0 +1,81 @@
|
||||
defmodule Plausible.Repo.Migrations.BackfillAcceptTrafficUntil do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# trials that are about to expire get extra 14 days
|
||||
# regardless of the effective end date, this still leaves a room for both notifications
|
||||
execute """
|
||||
UPDATE users
|
||||
SET accept_traffic_until = trial_expiry_date + 14
|
||||
WHERE
|
||||
trial_expiry_date IS NOT NULL
|
||||
AND
|
||||
trial_expiry_date >= CURRENT_DATE
|
||||
"""
|
||||
|
||||
# free plans
|
||||
execute """
|
||||
UPDATE users AS u
|
||||
SET accept_traffic_until = '2135-01-01'
|
||||
WHERE
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM subscriptions s
|
||||
WHERE
|
||||
user_id = u.id
|
||||
AND
|
||||
paddle_plan_id = 'free_10k'
|
||||
)
|
||||
"""
|
||||
|
||||
# abandoned accounts (trial ended and no valid subscriptions) still get a random
|
||||
# phase-out period so that both notifications can be delivered
|
||||
execute """
|
||||
UPDATE users
|
||||
SET accept_traffic_until = CURRENT_DATE + TRUNC(RANDOM() * (20 - 8 + 1) + 8)::int
|
||||
WHERE
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM subscriptions
|
||||
WHERE
|
||||
subscriptions.user_id = users.id
|
||||
)
|
||||
AND
|
||||
trial_expiry_date IS NOT NULL
|
||||
AND
|
||||
trial_expiry_date < CURRENT_DATE
|
||||
"""
|
||||
|
||||
# all the non-free subscriptions
|
||||
execute """
|
||||
UPDATE users u1
|
||||
SET accept_traffic_until = s.next_bill_date + 30
|
||||
FROM users u2
|
||||
INNER JOIN LATERAL (
|
||||
SELECT * FROM subscriptions sub WHERE u2.id = sub.user_id ORDER BY sub.inserted_at DESC LIMIT 1
|
||||
) s ON (true)
|
||||
WHERE
|
||||
u1.id = u2.id
|
||||
AND
|
||||
s.user_id = u1.id
|
||||
AND
|
||||
s.paddle_plan_id != 'free_10k'
|
||||
"""
|
||||
|
||||
# subscription for which current period needs payment)
|
||||
execute """
|
||||
UPDATE users u1
|
||||
SET accept_traffic_until = CURRENT_DATE + TRUNC(RANDOM() * (20 - 8 + 1) + 8)::int
|
||||
FROM users u2
|
||||
INNER JOIN LATERAL (
|
||||
SELECT * FROM subscriptions sub WHERE u2.id = sub.user_id ORDER BY sub.inserted_at DESC LIMIT 1
|
||||
) s ON (true)
|
||||
WHERE
|
||||
s.user_id = u1.id
|
||||
AND
|
||||
u1.id = u2.id
|
||||
AND
|
||||
s.next_bill_date < CURRENT_DATE
|
||||
"""
|
||||
end
|
||||
end
|
@ -112,10 +112,12 @@ defmodule Plausible.Billing.FeatureTest do
|
||||
{:ok, site} = unquote(mod).toggle(site)
|
||||
assert Map.get(site, unquote(property))
|
||||
assert unquote(mod).enabled?(site)
|
||||
refute unquote(mod).opted_out?(site)
|
||||
|
||||
{:ok, site} = unquote(mod).toggle(site)
|
||||
refute Map.get(site, unquote(property))
|
||||
refute unquote(mod).enabled?(site)
|
||||
assert unquote(mod).opted_out?(site)
|
||||
end
|
||||
|
||||
test "#{mod}.toggle/2 accepts an override option" do
|
||||
@ -126,12 +128,19 @@ defmodule Plausible.Billing.FeatureTest do
|
||||
refute unquote(mod).enabled?(site)
|
||||
end
|
||||
|
||||
test "#{mod}.toggle/2 errors when site owner does not have access to the feature" do
|
||||
test "#{mod}.toggle/2 errors when enabling a feature the site owner does not have access to the feature" do
|
||||
user = insert(:user, subscription: build(:growth_subscription))
|
||||
site = insert(:site, [{:members, [user]}, {unquote(property), false}])
|
||||
{:error, :upgrade_required} = unquote(mod).toggle(site)
|
||||
refute unquote(mod).enabled?(site)
|
||||
end
|
||||
|
||||
test "#{mod}.toggle/2 does not error when disabling a feature the site owner does not have access to" do
|
||||
user = insert(:user, subscription: build(:growth_subscription))
|
||||
site = insert(:site, [{:members, [user]}, {unquote(property), true}])
|
||||
{:ok, site} = unquote(mod).toggle(site)
|
||||
assert unquote(mod).opted_out?(site)
|
||||
end
|
||||
end
|
||||
|
||||
test "Plausible.Billing.Feature.Goals.toggle/2 toggles conversions_enabled on and off" do
|
||||
@ -140,10 +149,12 @@ defmodule Plausible.Billing.FeatureTest do
|
||||
{:ok, site} = Plausible.Billing.Feature.Goals.toggle(site)
|
||||
assert Map.get(site, :conversions_enabled)
|
||||
assert Plausible.Billing.Feature.Goals.enabled?(site)
|
||||
refute Plausible.Billing.Feature.Goals.opted_out?(site)
|
||||
|
||||
{:ok, site} = Plausible.Billing.Feature.Goals.toggle(site)
|
||||
refute Map.get(site, :conversions_enabled)
|
||||
refute Plausible.Billing.Feature.Goals.enabled?(site)
|
||||
assert Plausible.Billing.Feature.Goals.opted_out?(site)
|
||||
end
|
||||
|
||||
for mod <- [Plausible.Billing.Feature.Funnels, Plausible.Billing.Feature.Props] do
|
||||
@ -152,5 +163,11 @@ defmodule Plausible.Billing.FeatureTest do
|
||||
site = insert(:site, [{:members, [user]}, {unquote(mod).toggle_field(), true}])
|
||||
refute unquote(mod).enabled?(site)
|
||||
end
|
||||
|
||||
test "#{mod}.opted_out?/1 returns false when feature toggle is enabled even when user does not have access to the feature" do
|
||||
user = insert(:user, subscription: build(:growth_subscription))
|
||||
site = insert(:site, [{:members, [user]}, {unquote(mod).toggle_field(), true}])
|
||||
refute unquote(mod).opted_out?(site)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -12,6 +12,15 @@ defmodule Plausible.SitesTest do
|
||||
assert {:ok, %{site: %{domain: "example.com", timezone: "Europe/London"}}} =
|
||||
Sites.create(user, params)
|
||||
end
|
||||
|
||||
test "fails on invalid timezone" do
|
||||
user = insert(:user)
|
||||
|
||||
params = %{"domain" => "example.com", "timezone" => "blah"}
|
||||
|
||||
assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} =
|
||||
Sites.create(user, params)
|
||||
end
|
||||
end
|
||||
|
||||
describe "is_member?" do
|
||||
|
@ -26,6 +26,18 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "timezone is validated", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, "/api/v1/sites", %{
|
||||
"domain" => "some-site.domain",
|
||||
"timezone" => "d"
|
||||
})
|
||||
|
||||
assert json_response(conn, 400) == %{
|
||||
"error" => "timezone: is invalid"
|
||||
}
|
||||
end
|
||||
|
||||
test "timezone defaults to Etc/UTC", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, "/api/v1/sites", %{
|
||||
|
@ -523,6 +523,53 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "pageviews breakdown by event:page - imported data having pageviews=0 and visitors=n should be bypassed",
|
||||
%{conn: conn, site: site} do
|
||||
site =
|
||||
site
|
||||
|> Plausible.Site.start_import(~D[2005-01-01], Timex.today(), "Google Analytics", "ok")
|
||||
|> Plausible.Repo.update!()
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]),
|
||||
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]),
|
||||
build(:pageview,
|
||||
pathname: "/plausible.io",
|
||||
timestamp: ~N[2021-01-01 00:00:00]
|
||||
),
|
||||
build(:imported_pages,
|
||||
page: "/skip-me",
|
||||
date: ~D[2021-01-01],
|
||||
visitors: 1,
|
||||
pageviews: 0
|
||||
),
|
||||
build(:imported_pages,
|
||||
page: "/include-me",
|
||||
date: ~D[2021-01-01],
|
||||
visitors: 1,
|
||||
pageviews: 1
|
||||
)
|
||||
])
|
||||
|
||||
conn =
|
||||
get(conn, "/api/v1/stats/breakdown", %{
|
||||
"site_id" => site.domain,
|
||||
"period" => "day",
|
||||
"date" => "2021-01-01",
|
||||
"property" => "event:page",
|
||||
"with_imported" => "true",
|
||||
"metrics" => "pageviews"
|
||||
})
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"results" => [
|
||||
%{"page" => "/", "pageviews" => 2},
|
||||
%{"page" => "/plausible.io", "pageviews" => 1},
|
||||
%{"page" => "/include-me", "pageviews" => 1}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "breakdown by event:page", %{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]),
|
||||
|
@ -265,16 +265,17 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
populate_stats(site, [
|
||||
build(:event, name: "Signup"),
|
||||
build(:event, name: "Signup"),
|
||||
[
|
||||
build(:event,
|
||||
name: "Payment",
|
||||
pathname: "/checkout",
|
||||
revenue_reporting_amount: Decimal.new("10.00"),
|
||||
revenue_reporting_currency: "EUR"
|
||||
)
|
||||
])
|
||||
]
|
||||
|> Enum.concat(build_list(2, :event, name: "Signup"))
|
||||
|> Enum.concat(build_list(3, :pageview, pathname: "/checkout"))
|
||||
|> then(fn events -> populate_stats(site, events) end)
|
||||
|
||||
insert(:goal, %{site: site, event_name: "Signup"})
|
||||
insert(:goal, %{site: site, page_path: "/checkout"})
|
||||
@ -286,7 +287,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
assert [
|
||||
%{
|
||||
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
|
||||
"conversion_rate" => 33.3,
|
||||
"conversion_rate" => 16.7,
|
||||
"name" => "Payment",
|
||||
"events" => 1,
|
||||
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
|
||||
@ -294,7 +295,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
},
|
||||
%{
|
||||
"average_revenue" => nil,
|
||||
"conversion_rate" => 66.7,
|
||||
"conversion_rate" => 33.3,
|
||||
"name" => "Signup",
|
||||
"events" => 2,
|
||||
"total_revenue" => nil,
|
||||
@ -302,11 +303,11 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
},
|
||||
%{
|
||||
"average_revenue" => nil,
|
||||
"conversion_rate" => 33.3,
|
||||
"conversion_rate" => 50.0,
|
||||
"name" => "Visit /checkout",
|
||||
"events" => 1,
|
||||
"events" => 3,
|
||||
"total_revenue" => nil,
|
||||
"visitors" => 1
|
||||
"visitors" => 3
|
||||
}
|
||||
] == Enum.sort_by(response, & &1["name"])
|
||||
end
|
||||
@ -337,35 +338,94 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
describe "GET /api/stats/:domain/conversions - with goal filter" do
|
||||
setup [:create_user, :log_in, :create_new_site]
|
||||
|
||||
test "returns only the conversion that is filtered for", %{conn: conn, site: site} do
|
||||
test "does not consider custom event pathname as a pageview goal completion", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/"),
|
||||
build(:pageview, pathname: "/"),
|
||||
build(:pageview, pathname: "/register"),
|
||||
build(:pageview, pathname: "/register"),
|
||||
build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]),
|
||||
build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["B"])
|
||||
build(:event, pathname: "/register", name: "Signup")
|
||||
])
|
||||
|
||||
insert(:goal, %{site: site, page_path: "/register"})
|
||||
insert(:goal, %{site: site, event_name: "Signup"})
|
||||
|
||||
filters = Jason.encode!(%{goal: "Signup"})
|
||||
get_with_filter = fn filters ->
|
||||
path = "/api/stats/#{site.domain}/conversions"
|
||||
query = "?period=day&filters=#{Jason.encode!(filters)}"
|
||||
|
||||
conn =
|
||||
get(
|
||||
conn,
|
||||
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
|
||||
)
|
||||
get(conn, path <> query)
|
||||
|> json_response(200)
|
||||
end
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "Signup",
|
||||
"visitors" => 2,
|
||||
"events" => 2,
|
||||
"conversion_rate" => 33.3
|
||||
}
|
||||
]
|
||||
expected = [
|
||||
%{
|
||||
"name" => "Signup",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 33.3
|
||||
}
|
||||
]
|
||||
|
||||
# {:is, {:event, event}} filter type
|
||||
assert get_with_filter.(%{goal: "Signup"}) == expected
|
||||
|
||||
# {:member, clauses} filter type
|
||||
assert get_with_filter.(%{goal: "Signup|Whatever"}) == expected
|
||||
|
||||
# {:matches_member, clauses} filter type
|
||||
assert get_with_filter.(%{goal: "Signup|Visit /whatever*"}) == expected
|
||||
end
|
||||
|
||||
test "does not return custom events with the filtered pageview goal pathname", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/"),
|
||||
build(:pageview, pathname: "/register"),
|
||||
build(:event, pathname: "/register", name: "Signup")
|
||||
])
|
||||
|
||||
insert(:goal, %{site: site, page_path: "/register"})
|
||||
insert(:goal, %{site: site, page_path: "/regi**"})
|
||||
insert(:goal, %{site: site, event_name: "Signup"})
|
||||
|
||||
get_with_filter = fn filters ->
|
||||
path = "/api/stats/#{site.domain}/conversions"
|
||||
query = "?period=day&filters=#{Jason.encode!(filters)}"
|
||||
|
||||
get(conn, path <> query)
|
||||
|> json_response(200)
|
||||
end
|
||||
|
||||
expected = [
|
||||
%{
|
||||
"name" => "Visit /register",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 33.3
|
||||
},
|
||||
%{
|
||||
"name" => "Visit /regi**",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 33.3
|
||||
}
|
||||
]
|
||||
|
||||
# {:is, {:page, path}} filter type
|
||||
assert get_with_filter.(%{goal: "Visit+/register"}) == expected
|
||||
|
||||
# {:matches, {:page, expr}} filter type
|
||||
assert get_with_filter.(%{goal: "Visit+/regi**"}) == expected
|
||||
|
||||
# {:member, clauses} filter type
|
||||
assert get_with_filter.(%{goal: "Visit+/register|Whatever"}) == expected
|
||||
|
||||
# {:matches_member, clauses} filter type
|
||||
assert get_with_filter.(%{goal: "Visit+/register|Visit+/regi**"}) == expected
|
||||
end
|
||||
|
||||
test "can filter by multiple mixed goals", %{conn: conn, site: site} do
|
||||
@ -405,45 +465,6 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
]
|
||||
end
|
||||
|
||||
test "can filter by multiple negated mixed goals", %{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/"),
|
||||
build(:pageview, pathname: "/"),
|
||||
build(:pageview, pathname: "/another"),
|
||||
build(:pageview, pathname: "/register"),
|
||||
build(:event, name: "CTA"),
|
||||
build(:event, name: "Signup")
|
||||
])
|
||||
|
||||
insert(:goal, %{site: site, page_path: "/register"})
|
||||
insert(:goal, %{site: site, page_path: "/another"})
|
||||
insert(:goal, %{site: site, event_name: "CTA"})
|
||||
insert(:goal, %{site: site, event_name: "Signup"})
|
||||
|
||||
filters = Jason.encode!(%{goal: "!Signup|Visit /another"})
|
||||
|
||||
conn =
|
||||
get(
|
||||
conn,
|
||||
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "CTA",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 16.7
|
||||
},
|
||||
%{
|
||||
"name" => "Visit /register",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 16.7
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "can combine wildcard and no wildcard in matches_member", %{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/blog/post-1"),
|
||||
@ -515,75 +536,6 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "can filter by not_matches_member filter type on goals", %{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/another"),
|
||||
build(:pageview, pathname: "/another"),
|
||||
build(:pageview, pathname: "/blog/post-1"),
|
||||
build(:pageview, pathname: "/blog/post-2"),
|
||||
build(:event, name: "CTA"),
|
||||
build(:event, name: "Signup")
|
||||
])
|
||||
|
||||
insert(:goal, %{site: site, page_path: "/blog**"})
|
||||
insert(:goal, %{site: site, page_path: "/ano**"})
|
||||
insert(:goal, %{site: site, event_name: "CTA"})
|
||||
insert(:goal, %{site: site, event_name: "Signup"})
|
||||
|
||||
filters = Jason.encode!(%{goal: "!Signup|Visit /blog**"})
|
||||
|
||||
conn =
|
||||
get(
|
||||
conn,
|
||||
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "Visit /ano**",
|
||||
"visitors" => 2,
|
||||
"events" => 2,
|
||||
"conversion_rate" => 33.3
|
||||
},
|
||||
%{
|
||||
"name" => "CTA",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 16.7
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "can combine wildcard and no wildcard in not_matches_member", %{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview, pathname: "/blog/post-1"),
|
||||
build(:pageview, pathname: "/blog/post-2"),
|
||||
build(:pageview, pathname: "/billing/upgrade"),
|
||||
build(:pageview, pathname: "/register")
|
||||
])
|
||||
|
||||
insert(:goal, %{site: site, page_path: "/blog/**"})
|
||||
insert(:goal, %{site: site, page_path: "/billing/upgrade"})
|
||||
insert(:goal, %{site: site, page_path: "/register"})
|
||||
|
||||
filters = Jason.encode!(%{goal: "!Visit /blog/**|Visit /billing/upgrade"})
|
||||
|
||||
conn =
|
||||
get(
|
||||
conn,
|
||||
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "Visit /register",
|
||||
"visitors" => 1,
|
||||
"events" => 1,
|
||||
"conversion_rate" => 25
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/stats/:domain/conversions - with goal and prop=(none) filter" do
|
||||
|
@ -146,7 +146,6 @@ defmodule PlausibleWeb.StatsControllerTest do
|
||||
assert ~c"cities.csv" in zip
|
||||
assert ~c"conversions.csv" in zip
|
||||
assert ~c"countries.csv" in zip
|
||||
assert ~c"custom_props.csv" in zip
|
||||
assert ~c"devices.csv" in zip
|
||||
assert ~c"entry_pages.csv" in zip
|
||||
assert ~c"exit_pages.csv" in zip
|
||||
@ -161,15 +160,54 @@ defmodule PlausibleWeb.StatsControllerTest do
|
||||
assert ~c"utm_terms.csv" in zip
|
||||
end
|
||||
|
||||
test "does not export custom properties when site owner is on a growth plan", %{
|
||||
test "exports only internally used props in custom_props.csv for a growth plan", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
user: user
|
||||
site: site
|
||||
} do
|
||||
insert(:growth_subscription, user: user)
|
||||
response = conn |> get("/" <> site.domain <> "/export") |> response(200)
|
||||
{:ok, site} = Plausible.Props.allow(site, ["author"])
|
||||
|
||||
site = Repo.preload(site, :owner)
|
||||
insert(:growth_subscription, user: site.owner)
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, "meta.key": ["author"], "meta.value": ["a"]),
|
||||
build(:event, name: "File Download", "meta.key": ["url"], "meta.value": ["b"])
|
||||
])
|
||||
|
||||
conn = get(conn, "/" <> site.domain <> "/export?period=day")
|
||||
assert response = response(conn, 200)
|
||||
{:ok, zip} = :zip.unzip(response, [:memory])
|
||||
|
||||
{_filename, result} =
|
||||
Enum.find(zip, fn {filename, _data} -> filename == ~c"custom_props.csv" end)
|
||||
|
||||
assert parse_csv(result) == [
|
||||
["property", "value", "visitors", "events", "percentage"],
|
||||
["url", "b", "1", "1", "50.0"],
|
||||
["url", "(none)", "1", "1", "50.0"],
|
||||
[""]
|
||||
]
|
||||
end
|
||||
|
||||
test "does not include custom_props.csv for a growth plan if no internal props used", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
{:ok, site} = Plausible.Props.allow(site, ["author"])
|
||||
|
||||
site = Repo.preload(site, :owner)
|
||||
insert(:growth_subscription, user: site.owner)
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, "meta.key": ["author"], "meta.value": ["a"])
|
||||
])
|
||||
|
||||
{:ok, zip} =
|
||||
conn
|
||||
|> get("/#{site.domain}/export?period=day")
|
||||
|> response(200)
|
||||
|> :zip.unzip([:memory])
|
||||
|
||||
files = Map.new(zip)
|
||||
|
||||
refute Map.has_key?(files, ~c"custom_props.csv")
|
||||
@ -190,7 +228,7 @@ defmodule PlausibleWeb.StatsControllerTest do
|
||||
|> response(400)
|
||||
end
|
||||
|
||||
test "exports allowed event props", %{conn: conn, site: site} do
|
||||
test "exports allowed event props for a trial account", %{conn: conn, site: site} do
|
||||
{:ok, site} = Plausible.Props.allow(site, ["author", "logged_in"])
|
||||
|
||||
populate_stats(site, [
|
||||
|
@ -247,13 +247,14 @@ defmodule PlausibleWeb.EmailTest do
|
||||
|
||||
describe "approaching accept_traffic_until" do
|
||||
test "renders first warning" do
|
||||
user = build(:user)
|
||||
user = build(:user, name: "John Doe")
|
||||
|
||||
%{html_body: body, subject: subject} =
|
||||
PlausibleWeb.Email.approaching_accept_traffic_until(user)
|
||||
|
||||
assert subject == "We'll stop counting your stats"
|
||||
assert body =~ plausible_link()
|
||||
assert body =~ "Hey John,"
|
||||
|
||||
assert body =~
|
||||
"We've noticed that you're still sending us stats so we're writing to inform you that we'll stop accepting stats from your sites next week."
|
||||
|
@ -12,6 +12,7 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
|
||||
tomorrow = today |> Date.add(+1)
|
||||
|
||||
user1 = insert(:user, accept_traffic_until: next_week)
|
||||
|
||||
user2 = insert(:user, accept_traffic_until: tomorrow)
|
||||
|
||||
_site1 = insert(:site, members: [user1])
|
||||
@ -24,6 +25,37 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
|
||||
refute_notifications(user2.email)
|
||||
end
|
||||
|
||||
test "does not send any notifications when site has stats older than 2d" do
|
||||
today = Date.utc_today()
|
||||
next_week = today |> Date.add(+7)
|
||||
|
||||
user = insert(:user, accept_traffic_until: next_week)
|
||||
|
||||
:site
|
||||
|> insert(members: [user])
|
||||
|> populate_stats([build(:pageview, timestamp: Date.add(today, -3))])
|
||||
|
||||
{:ok, 1} = AcceptTrafficUntil.perform(nil)
|
||||
|
||||
refute_notifications(user.email)
|
||||
end
|
||||
|
||||
test "does send notification when last stat is 2d old" do
|
||||
today = Date.utc_today()
|
||||
next_week = today |> Date.add(+7)
|
||||
|
||||
user =
|
||||
insert(:user, accept_traffic_until: next_week)
|
||||
|
||||
:site
|
||||
|> insert(members: [user])
|
||||
|> populate_stats([build(:pageview, timestamp: Date.add(today, -2))])
|
||||
|
||||
{:ok, 1} = AcceptTrafficUntil.perform(nil)
|
||||
|
||||
assert_weekly_notification(user.email)
|
||||
end
|
||||
|
||||
test "tomorrow: sends one e-mail" do
|
||||
tomorrow = Date.utc_today() |> Date.add(+1)
|
||||
user = insert(:user, accept_traffic_until: tomorrow)
|
||||
@ -107,16 +139,19 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
|
||||
|
||||
defp assert_weekly_notification(email) when is_binary(email) do
|
||||
assert_email_delivered_with(
|
||||
html_body: ~r/Hey Jane,/,
|
||||
to: [nil: email],
|
||||
subject: PlausibleWeb.Email.approaching_accept_traffic_until(%{email: email}).subject
|
||||
subject:
|
||||
PlausibleWeb.Email.approaching_accept_traffic_until(%{name: "", email: email}).subject
|
||||
)
|
||||
end
|
||||
|
||||
defp assert_final_notification(email) when is_binary(email) do
|
||||
assert_email_delivered_with(
|
||||
html_body: ~r/Hey Jane,/,
|
||||
to: [nil: email],
|
||||
subject:
|
||||
PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(%{email: email}).subject
|
||||
PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(%{name: "", email: email}).subject
|
||||
)
|
||||
end
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user