Implement UI for multiple imports (#3727)

* Create a stub of site settings section for imports and exports

* Use legacy site import indication to determine UA import handling

* Add provisional logos for upcoming import sources

* Stub basics of import page

* Add very rudimentary support for multiple UA imports

* Implement imports list as live view

* Add support for opening LV modal from backend and closing from frontend

* Introduce notion of themes to `button` and `button_link` components

* Add confirmation modal on deleting import

* Swap GA4 logo

* Implement disabled state support for `button_link` component

* Disable export and non-implemented import sources

* Use native starts start date for upper boundary of import time range

* Ensure integrations view uses legacy UA import flow

* Remove unnecessary preload in SiteController

* Remove unnecessary exception for legacy imports

* Move API controller stats tests under PlausibleWeb

* Test listing imports

* Add test for explicit listener setup

* Add tests for legacy flag state in UA importer

* Add test for purging legacy import data

* Add tests for `Sites.native_stats_start_date`

* Test forgetting imports

* Add `Stats.Clickhouse.imported_pageview_counts/1` and fix test flakiness

* Show page view counts on imports list

* Add tests for static imports and exports view

* Adjust button look slightly

* Use `case` instead of `cond`

* Make feature flag customisable per site

* Fix buttons and empty state styling

* Add another import to seeds

* Use JS confirm dialog instead of modal for deletion confirmations

* Revert "Add support for opening LV modal from backend and closing from frontend"

This reverts commit 260e6c753032b451542e24be9edc2118790b5a00.

* Default `legacy` to false when inserting new import jobs

* Drop `method` attribute from `button_link` and `unstyled_link` components
This commit is contained in:
Adrian Gruntkowski 2024-02-28 09:34:04 +01:00 committed by GitHub
parent fdbe4cc0a4
commit 39aa81a16f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 932 additions and 84 deletions

View File

@ -17,9 +17,9 @@ defmodule Plausible.Google.Api do
Jason.encode!([site_id, redirect_to])
end
def import_authorize_url(site_id, redirect_to) do
def import_authorize_url(site_id, redirect_to, legacy \\ true) do
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@import_scope}&state=" <>
Jason.encode!([site_id, redirect_to])
Jason.encode!([site_id, redirect_to, legacy])
end
def fetch_verified_properties(auth) do

View File

@ -40,6 +40,9 @@ defmodule Plausible.Imported do
@spec tables() :: [String.t()]
def tables, do: @table_names
@spec max_complete_imports() :: non_neg_integer()
def max_complete_imports(), do: @max_complete_imports
@spec load_import_data(Site.t()) :: Site.t()
def load_import_data(%{import_data_loaded: true} = site), do: site
@ -56,10 +59,24 @@ defmodule Plausible.Imported do
}
end
@spec get_import(non_neg_integer()) :: SiteImport.t() | nil
def get_import(import_id) do
Repo.get(SiteImport, import_id)
end
defdelegate listen(), to: Imported.Importer
@spec list_all_imports(Site.t()) :: [SiteImport.t()]
def list_all_imports(site) do
from(i in SiteImport, where: i.site_id == ^site.id)
|> Repo.all()
imports =
from(i in SiteImport, where: i.site_id == ^site.id, order_by: [desc: i.inserted_at])
|> Repo.all()
if site.imported_data && not Enum.any?(imports, & &1.legacy) do
imports ++ [SiteImport.from_legacy(site.imported_data)]
else
imports
end
end
@spec list_complete_import_ids(Site.t()) :: [non_neg_integer()]

View File

@ -155,7 +155,7 @@ defmodule Plausible.Imported.Importer do
def new_import(source, site, user, opts, before_start_fun) do
import_params =
opts
|> Keyword.take([:start_date, :end_date])
|> Keyword.take([:start_date, :end_date, :legacy])
|> Keyword.put(:source, source)
|> Map.new()
@ -205,11 +205,21 @@ defmodule Plausible.Imported.Importer do
Oban.Notifier.notify(Oban, @oban_channel, %{event => site_import.id})
end
@doc """
Allows to explicitly start listening for importer job notifications.
Listener must explicitly filter out a subset of imports that apply to the given context.
"""
@spec listen() :: :ok
def listen() do
:ok = Oban.Notifier.listen([@oban_channel])
end
defp schedule_job(site_import, opts) do
{listen?, opts} = Keyword.pop(opts, :listen?, false)
if listen? do
:ok = Oban.Notifier.listen([@oban_channel])
:ok = listen()
end
opts

View File

@ -20,6 +20,7 @@ defmodule Plausible.Imported.SiteImport do
field :end_date, :date
field :source, Ecto.Enum, values: ImportSources.names()
field :status, Ecto.Enum, values: @statuses
field :legacy, :boolean, default: false
belongs_to :site, Site
belongs_to :imported_by, User
@ -37,10 +38,29 @@ defmodule Plausible.Imported.SiteImport do
def label(%__MODULE__{source: source}), do: ImportSources.by_name(source).label()
def label(%Site.ImportedData{source: source}), do: source
@spec from_legacy(Site.ImportedData.t()) :: t()
def from_legacy(%Site.ImportedData{} = data) do
status =
case data.status do
"ok" -> completed()
"error" -> failed()
_ -> importing()
end
%__MODULE__{
id: 0,
legacy: true,
start_date: data.start_date,
end_date: data.end_date,
source: :universal_analytics,
status: status
}
end
@spec create_changeset(Site.t(), User.t(), map()) :: Ecto.Changeset.t()
def create_changeset(site, user, params) do
%__MODULE__{}
|> cast(params, [:source, :start_date, :end_date])
|> cast(params, [:source, :start_date, :end_date, :legacy])
|> validate_required([:source])
|> put_assoc(:site, site)
|> put_assoc(:imported_by, user)

View File

@ -20,37 +20,43 @@ defmodule Plausible.Imported.UniversalAnalytics do
@impl true
def before_start(site_import) do
site = Repo.preload(site_import, :site).site
if site_import.legacy do
site = Repo.preload(site_import, :site).site
site
|> Plausible.Site.start_import(
site_import.start_date,
site_import.end_date,
label()
)
|> Repo.update!()
site
|> Plausible.Site.start_import(
site_import.start_date,
site_import.end_date,
label()
)
|> Repo.update!()
end
:ok
end
@impl true
def on_success(site_import, _extra_data) do
site = Repo.preload(site_import, :site).site
if site_import.legacy do
site = Repo.preload(site_import, :site).site
site
|> Plausible.Site.import_success()
|> Repo.update!()
site
|> Plausible.Site.import_success()
|> Repo.update!()
end
:ok
end
@impl true
def on_failure(site_import) do
site = Repo.preload(site_import, :site).site
if site_import.legacy do
site = Repo.preload(site_import, :site).site
site
|> Plausible.Site.import_failure()
|> Repo.update!()
site
|> Plausible.Site.import_failure()
|> Repo.update!()
end
:ok
end

View File

@ -58,17 +58,19 @@ defmodule Plausible.Purge do
def delete_imported_stats!(%Plausible.Imported.SiteImport{} = site_import) do
site_import = Repo.preload(site_import, :site)
delete_imported_stats!(site_import.site, site_import.id)
:ok
end
def delete_imported_stats!(%Plausible.Site{} = site, import_id) do
Enum.each(Plausible.Imported.tables(), fn table ->
sql = "ALTER TABLE #{table} DELETE WHERE site_id = {$0:UInt64} AND import_id = {$1:UInt64}"
Ecto.Adapters.SQL.query!(Plausible.ImportDeletionRepo, sql, [
site_import.site_id,
site_import.id
])
Ecto.Adapters.SQL.query!(Plausible.ImportDeletionRepo, sql, [site.id, import_id])
end)
Plausible.Sites.clear_stats_start_date!(site_import.site)
Plausible.Sites.clear_stats_start_date!(site)
:ok
end

View File

@ -246,7 +246,7 @@ defmodule Plausible.Sites do
start_date =
[
site.earliest_import_start_date,
Plausible.Stats.Clickhouse.pageview_start_date_local(site)
native_stats_start_date(site)
]
|> Enum.reject(&is_nil/1)
|> Enum.min(Date, fn -> nil end)
@ -261,6 +261,11 @@ defmodule Plausible.Sites do
end
end
@spec native_stats_start_date(Site.t()) :: Date.t() | nil
def native_stats_start_date(site) do
Plausible.Stats.Clickhouse.pageview_start_date_local(site)
end
def has_stats?(site) do
!!stats_start_date(site)
end

View File

@ -39,6 +39,17 @@ defmodule Plausible.Stats.Clickhouse do
)
end
@spec imported_pageview_counts(Plausible.Site.t()) :: %{non_neg_integer() => non_neg_integer()}
def imported_pageview_counts(site) do
from(i in "imported_visitors",
where: i.site_id == ^site.id,
group_by: i.import_id,
select: {i.import_id, sum(i.pageviews)}
)
|> Plausible.ClickhouseRepo.all()
|> Map.new()
end
def usage_breakdown([d | _] = domains, date_range) when is_binary(d) do
Enum.chunk_every(domains, 300)
|> Enum.reduce({0, 0}, fn domains, {pageviews_total, custom_events_total} ->

View File

@ -19,7 +19,18 @@ defmodule PlausibleWeb.Components.Generic do
}
}
@button_themes %{
"primary" => "bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600",
"bright" =>
"border border-gray-200 bg-gray-100 dark:bg-gray-300 text-gray-800 hover:bg-gray-200 focus-visible:outline-gray-100",
"danger" =>
"border border-gray-300 dark:border-gray-500 text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:border-blue-300 active:text-red-800"
}
@button_base_class "inline-flex items-center justify-center gap-x-2 rounded-md px-3.5 py-2.5 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"
attr(:type, :string, default: "button")
attr(:theme, :string, default: "primary")
attr(:class, :string, default: "")
attr(:disabled, :boolean, default: false)
attr(:rest, :global)
@ -27,12 +38,19 @@ defmodule PlausibleWeb.Components.Generic do
slot(:inner_block)
def button(assigns) do
assigns =
assign(assigns,
button_base_class: @button_base_class,
theme_class: @button_themes[assigns.theme]
)
~H"""
<button
type={@type}
disabled={@disabled}
class={[
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700",
@button_base_class,
@theme_class,
@class
]}
{@rest}
@ -44,16 +62,32 @@ defmodule PlausibleWeb.Components.Generic do
attr(:href, :string, required: true)
attr(:class, :string, default: "")
attr(:theme, :string, default: "primary")
attr(:disabled, :boolean, default: false)
attr(:rest, :global)
slot(:inner_block)
def button_link(assigns) do
theme_class =
if assigns.disabled do
"bg-gray-400 text-white dark:text-white dark:text-gray-400 dark:bg-gray-700 pointer-events-none cursor-default"
else
@button_themes[assigns.theme]
end
assigns =
assign(assigns,
button_base_class: @button_base_class,
theme_class: theme_class
)
~H"""
<.link
href={@href}
class={[
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400 dark:disabled:bg-gray-800",
@button_base_class,
@theme_class,
@class
]}
{@rest}

View File

@ -697,9 +697,24 @@ defmodule PlausibleWeb.AuthController do
end
def google_auth_callback(conn, %{"error" => error, "state" => state} = params) do
[site_id, _redirect_to] = Jason.decode!(state)
[site_id, _redirected_to, legacy] =
case Jason.decode!(state) do
[site_id, redirect_to] ->
[site_id, redirect_to, true]
[site_id, redirect_to, legacy] ->
[site_id, redirect_to, legacy]
end
site = Repo.get(Plausible.Site, site_id)
redirect_route =
if legacy do
Routes.site_path(conn, :settings_integrations, site.domain)
else
Routes.site_path(conn, :settings_imports_exports, site.domain)
end
case error do
"access_denied" ->
conn
@ -707,7 +722,7 @@ defmodule PlausibleWeb.AuthController do
:error,
"We were unable to authenticate your Google Analytics account. Please check that you have granted us permission to 'See and download your Google Analytics data' and try again."
)
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|> redirect(external: redirect_route)
message when message in ["server_error", "temporarily_unavailable"] ->
conn
@ -715,7 +730,7 @@ defmodule PlausibleWeb.AuthController do
:error,
"We are unable to authenticate your Google Analytics account because Google's authentication service is temporarily unavailable. Please try again in a few moments."
)
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|> redirect(external: redirect_route)
_any ->
Sentry.capture_message("Google OAuth callback failed. Reason: #{inspect(params)}")
@ -725,13 +740,22 @@ defmodule PlausibleWeb.AuthController do
:error,
"We were unable to authenticate your Google Analytics account. If the problem persists, please contact support for assistance."
)
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|> redirect(external: redirect_route)
end
end
def google_auth_callback(conn, %{"code" => code, "state" => state}) do
res = Plausible.Google.HTTP.fetch_access_token(code)
[site_id, redirect_to] = Jason.decode!(state)
[site_id, redirect_to, legacy] =
case Jason.decode!(state) do
[site_id, redirect_to] ->
[site_id, redirect_to, true]
[site_id, redirect_to, legacy] ->
[site_id, redirect_to, legacy]
end
site = Repo.get(Plausible.Site, site_id)
expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), res["expires_in"])
@ -742,7 +766,8 @@ defmodule PlausibleWeb.AuthController do
Routes.site_path(conn, :import_from_google_view_id_form, site.domain,
access_token: res["access_token"],
refresh_token: res["refresh_token"],
expires_at: NaiveDateTime.to_iso8601(expires_at)
expires_at: NaiveDateTime.to_iso8601(expires_at),
legacy: legacy
)
)

View File

@ -4,10 +4,12 @@ defmodule PlausibleWeb.SiteController do
alias Plausible.Sites
alias Plausible.Billing.Quota
plug PlausibleWeb.RequireAccountPlug
plug(PlausibleWeb.RequireAccountPlug)
plug PlausibleWeb.AuthorizeSiteAccess,
[:owner, :admin, :super_admin] when action not in [:new, :create_site]
plug(
PlausibleWeb.AuthorizeSiteAccess,
[:owner, :admin, :super_admin] when action not in [:new, :create_site]
)
def new(conn, _params) do
current_user = conn.assigns[:current_user]
@ -62,10 +64,11 @@ defmodule PlausibleWeb.SiteController do
is_first_site =
!Repo.exists?(
from sm in Plausible.Site.Membership,
from(sm in Plausible.Site.Membership,
where:
sm.user_id == ^user.id and
sm.site_id != ^site.id
)
)
conn
@ -144,7 +147,7 @@ defmodule PlausibleWeb.SiteController do
def settings_visibility(conn, _params) do
site = conn.assigns[:site]
shared_links = Repo.all(from l in Plausible.Site.SharedLink, where: l.site_id == ^site.id)
shared_links = Repo.all(from(l in Plausible.Site.SharedLink, where: l.site_id == ^site.id))
conn
|> render("settings_visibility.html",
@ -267,6 +270,23 @@ defmodule PlausibleWeb.SiteController do
)
end
def settings_imports_exports(conn, _params) do
site = conn.assigns.site
if FunWithFlags.enabled?(:imports_exports, for: site) do
conn
|> render("settings_imports_exports.html",
site: site,
dogfood_page_path: "/:dashboard/settings/imports-exports",
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
else
conn
|> redirect(external: Routes.site_path(conn, :settings, site.domain))
end
end
def update_google_auth(conn, %{"google_auth" => attrs}) do
site = conn.assigns[:site] |> Repo.preload(:google_auth)
@ -374,7 +394,7 @@ defmodule PlausibleWeb.SiteController do
def disable_weekly_report(conn, _params) do
site = conn.assigns[:site]
Repo.delete_all(from wr in Plausible.Site.WeeklyReport, where: wr.site_id == ^site.id)
Repo.delete_all(from(wr in Plausible.Site.WeeklyReport, where: wr.site_id == ^site.id))
conn
|> put_flash(:success, "You will not receive weekly email reports going forward")
@ -428,7 +448,7 @@ defmodule PlausibleWeb.SiteController do
def disable_monthly_report(conn, _params) do
site = conn.assigns[:site]
Repo.delete_all(from mr in Plausible.Site.MonthlyReport, where: mr.site_id == ^site.id)
Repo.delete_all(from(mr in Plausible.Site.MonthlyReport, where: mr.site_id == ^site.id))
conn
|> put_flash(:success, "You will not receive monthly email reports going forward")
@ -488,7 +508,7 @@ defmodule PlausibleWeb.SiteController do
def disable_spike_notification(conn, _params) do
site = conn.assigns[:site]
Repo.delete_all(from mr in Plausible.Site.SpikeNotification, where: mr.site_id == ^site.id)
Repo.delete_all(from(mr in Plausible.Site.SpikeNotification, where: mr.site_id == ^site.id))
conn
|> put_flash(:success, "Spike notification disabled")
@ -604,9 +624,10 @@ defmodule PlausibleWeb.SiteController do
site_id = site.id
case Repo.delete_all(
from l in Plausible.Site.SharedLink,
from(l in Plausible.Site.SharedLink,
where: l.slug == ^slug,
where: l.site_id == ^site_id
)
) do
{1, _} ->
conn
@ -624,7 +645,8 @@ defmodule PlausibleWeb.SiteController do
"view_id" => view_id,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
"expires_at" => expires_at,
"legacy" => legacy
}) do
site = conn.assigns[:site]
@ -636,6 +658,7 @@ defmodule PlausibleWeb.SiteController do
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at,
legacy: legacy,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
@ -643,8 +666,16 @@ defmodule PlausibleWeb.SiteController do
def import_from_google_view_id_form(conn, %{
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
"expires_at" => expires_at,
"legacy" => legacy
}) do
redirect_route =
if legacy == "true" do
Routes.site_path(conn, :settings_integrations, conn.assigns.site.domain)
else
Routes.site_path(conn, :settings_imports_exports, conn.assigns.site.domain)
end
case Plausible.Google.Api.list_views(access_token) do
{:ok, view_ids} ->
conn
@ -655,6 +686,7 @@ defmodule PlausibleWeb.SiteController do
expires_at: expires_at,
site: conn.assigns.site,
view_ids: view_ids,
legacy: legacy,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
@ -664,7 +696,7 @@ defmodule PlausibleWeb.SiteController do
:error,
"We were unable to authenticate your Google Analytics account. Please check that you have granted us permission to 'See and download your Google Analytics data' and try again."
)
|> redirect(to: Routes.site_path(conn, :settings_general, conn.assigns.site.domain))
|> redirect(external: redirect_route)
{:error, _any} ->
conn
@ -672,7 +704,7 @@ defmodule PlausibleWeb.SiteController do
:error,
"We were unable to list your Google Analytics properties. If the problem persists, please contact support for assistance."
)
|> redirect(to: Routes.site_path(conn, :settings_general, conn.assigns.site.domain))
|> redirect(external: redirect_route)
end
end
@ -682,7 +714,8 @@ defmodule PlausibleWeb.SiteController do
"view_id" => view_id,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
"expires_at" => expires_at,
"legacy" => legacy
}) do
site = conn.assigns[:site]
start_date = Plausible.Google.HTTP.get_analytics_start_date(view_id, access_token)
@ -701,6 +734,7 @@ defmodule PlausibleWeb.SiteController do
site: site,
view_ids: view_ids,
selected_view_id_error: "No data found. Nothing to import",
legacy: legacy,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
@ -712,7 +746,8 @@ defmodule PlausibleWeb.SiteController do
view_id: view_id,
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at
expires_at: expires_at,
legacy: legacy
)
)
else
@ -722,7 +757,8 @@ defmodule PlausibleWeb.SiteController do
view_id: view_id,
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at
expires_at: expires_at,
legacy: legacy
)
)
end
@ -733,12 +769,14 @@ defmodule PlausibleWeb.SiteController do
"view_id" => view_id,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
"expires_at" => expires_at,
"legacy" => legacy
}) do
site = conn.assigns[:site]
start_date = Plausible.Google.HTTP.get_analytics_start_date(view_id, access_token)
end_date = Plausible.Sites.stats_start_date(site) || Timex.today(site.timezone)
end_date = Plausible.Sites.native_stats_start_date(site) || Timex.today(site.timezone)
{:ok, {view_name, view_id}} = Plausible.Google.Api.get_view(access_token, view_id)
@ -753,6 +791,7 @@ defmodule PlausibleWeb.SiteController do
selected_view_id_name: view_name,
start_date: start_date,
end_date: end_date,
legacy: legacy,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
@ -763,11 +802,19 @@ defmodule PlausibleWeb.SiteController do
"end_date" => end_date,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
"expires_at" => expires_at,
"legacy" => legacy
}) do
site = conn.assigns.site
current_user = conn.assigns.current_user
redirect_route =
if legacy == "true" do
Routes.site_path(conn, :settings_integrations, site.domain)
else
Routes.site_path(conn, :settings_imports_exports, site.domain)
end
{:ok, _} =
Plausible.Imported.UniversalAnalytics.new_import(
site,
@ -777,12 +824,49 @@ defmodule PlausibleWeb.SiteController do
end_date: end_date,
access_token: access_token,
refresh_token: refresh_token,
token_expires_at: expires_at
token_expires_at: expires_at,
legacy: legacy == "true"
)
conn
|> put_flash(:success, "Import scheduled. An email will be sent when it completes.")
|> redirect(external: Routes.site_path(conn, :settings_integrations, site.domain))
|> redirect(external: redirect_route)
end
def forget_import(conn, %{"import_id" => import_id}) do
site = conn.assigns.site
cond do
import_id == "0" ->
Plausible.Purge.delete_imported_stats!(site, 0)
site
|> Plausible.Site.remove_imported_data()
|> Repo.update!()
site_import = Plausible.Imported.get_import(import_id) ->
Oban.cancel_all_jobs(
from(j in Oban.Job,
where:
j.queue == "analytics_imports" and
fragment("(? ->> 'import_id')::int", j.args) == ^site_import.id
)
)
Plausible.Purge.delete_imported_stats!(site_import)
Plausible.Repo.delete!(site_import)
if site_import.legacy do
site
|> Plausible.Site.remove_imported_data()
|> Repo.update!()
end
end
conn
|> put_flash(:success, "Imported data has been cleared")
|> redirect(external: Routes.site_path(conn, :settings_imports_exports, site.domain))
end
def forget_imported(conn, _params) do
@ -793,19 +877,13 @@ defmodule PlausibleWeb.SiteController do
|> Plausible.Imported.list_all_imports()
|> Enum.map(& &1.id)
import_ids =
if site.imported_data do
[0 | import_ids]
else
import_ids
end
if import_ids != [] do
Oban.cancel_all_jobs(
from j in Oban.Job,
from(j in Oban.Job,
where:
j.queue == "analytics_imports" and
fragment("(? ->> 'import_id')::int", j.args) in ^import_ids
)
)
Plausible.Purge.delete_imported_stats!(site)

View File

@ -0,0 +1,144 @@
defmodule PlausibleWeb.Live.ImportsExportsSettings do
@moduledoc """
LiveView allowing listing and deleting imports.
"""
use PlausibleWeb, :live_view
use Phoenix.HTML
import PlausibleWeb.Components.Generic
import PlausibleWeb.TextHelpers
alias Plausible.Imported
alias Plausible.Imported.SiteImport
alias Plausible.Sites
require Plausible.Imported.SiteImport
def mount(
_params,
%{"domain" => domain, "current_user_id" => user_id},
socket
) do
socket =
socket
|> assign_new(:site, fn ->
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:site_imports, fn %{site: site} ->
site
|> Imported.list_all_imports()
|> Enum.map(&%{site_import: &1, live_status: &1.status})
end)
|> assign_new(:pageview_counts, fn %{site: site} ->
Plausible.Stats.Clickhouse.imported_pageview_counts(site)
end)
|> assign_new(:current_user, fn ->
Plausible.Repo.get(Plausible.Auth.User, user_id)
end)
:ok = Imported.listen()
{:ok, assign(socket, max_imports: Imported.max_complete_imports())}
end
def render(assigns) do
~H"""
<header class="relative border-b border-gray-200 pb-4">
<h3 class="mt-8 text-md leading-6 font-medium text-gray-900 dark:text-gray-100">
Existing Imports
</h3>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
A maximum of <%= @max_imports %> imports at any time is allowed.
</p>
</header>
<div
:if={Enum.empty?(@site_imports)}
class="text-gray-800 dark:text-gray-200 text-center mt-8 mb-12"
>
<p>There are no imports yet for this site.</p>
</div>
<ul :if={not Enum.empty?(@site_imports)}>
<li :for={entry <- @site_imports} class="py-4 flex items-center justify-between space-x-4">
<div class="flex flex-col">
<p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100">
Import from <%= Plausible.Imported.SiteImport.label(entry.site_import) %>
<span :if={entry.live_status == SiteImport.completed()} class="text-xs font-normal">
(<%= Map.get(@pageview_counts, entry.site_import.id, 0) %> page views)
</span>
<Heroicons.clock
:if={entry.live_status == SiteImport.pending()}
class="inline-block h-6 w-5 text-indigo-600 dark:text-green-600"
/>
<.spinner
:if={entry.live_status == SiteImport.importing()}
class="inline-block h-6 w-5 text-indigo-600 dark:text-green-600"
/>
<Heroicons.check
:if={entry.live_status == SiteImport.completed()}
class="inline-block h-6 w-5 text-indigo-600 dark:text-green-600"
/>
<Heroicons.exclamation_triangle
:if={entry.live_status == SiteImport.failed()}
class="inline-block h-6 w-5 text-indigo-600 dark:text-green-600"
/>
</p>
<p class="text-sm leading-5 text-gray-500 dark:text-gray-200">
From <%= format_date(entry.site_import.start_date) %> to <%= format_date(
entry.site_import.end_date
) %> (created on <%= format_date(
entry.site_import.inserted_at || entry.site_import.start_date
) %>)
</p>
</div>
<.button
data-to={"/#{URI.encode_www_form(@site.domain)}/settings/forget-import/#{entry.site_import.id}"}
theme="danger"
data-method="delete"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
class="sm:ml-3 sm:w-auto w-full"
data-confirm="Are you sure you want to delete this import?"
>
<span :if={entry.live_status in [SiteImport.completed(), SiteImport.failed()]}>
Delete Import
</span>
<span :if={entry.live_status not in [SiteImport.completed(), SiteImport.failed()]}>
Cancel Import
</span>
</.button>
</li>
</ul>
"""
end
def handle_info({:notification, :analytics_imports_jobs, status}, socket) do
[{status_str, import_id}] = Enum.to_list(status)
{site_imports, updated?} = update_imports(socket.assigns.site_imports, import_id, status_str)
pageview_counts =
if updated? do
Plausible.Stats.Clickhouse.imported_pageview_counts(socket.assigns.site)
else
socket.assigns.pageview_counts
end
{:noreply, assign(socket, site_imports: site_imports, pageview_counts: pageview_counts)}
end
defp update_imports(site_imports, import_id, status_str) do
Enum.map_reduce(site_imports, false, fn
%{site_import: %{id: ^import_id}} = entry, _changed? ->
new_status =
case status_str do
"complete" -> SiteImport.completed()
"fail" -> SiteImport.failed()
"transient_fail" -> SiteImport.importing()
end
{%{entry | live_status: new_status}, true}
entry, changed? ->
{entry, changed?}
end)
end
end

View File

@ -360,6 +360,7 @@ defmodule PlausibleWeb.Router do
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
get "/:website/settings/integrations", SiteController, :settings_integrations
get "/:website/settings/shields/:shield", SiteController, :settings_shields
get "/:website/settings/imports-exports", SiteController, :settings_imports_exports
put "/:website/settings/features/visibility/:setting",
SiteController,
@ -385,6 +386,7 @@ defmodule PlausibleWeb.Router do
get "/:website/import/google-analytics/confirm", SiteController, :import_from_google_confirm
post "/:website/settings/google-import", SiteController, :import_from_google
delete "/:website/settings/forget-imported", SiteController, :forget_imported
delete "/:website/settings/forget-import/:import_id", SiteController, :forget_import
get "/:domain/export", StatsController, :csv_export
get "/:domain/*path", StatsController, :stats

View File

@ -4,6 +4,7 @@
<%= hidden_input(f, :access_token, value: @access_token) %>
<%= hidden_input(f, :refresh_token, value: @refresh_token) %>
<%= hidden_input(f, :expires_at, value: @expires_at) %>
<%= hidden_input(f, :legacy, value: @legacy) %>
<%= case @start_date do %>
<% {:ok, start_date} -> %>

View File

@ -22,5 +22,5 @@
</p>
</div>
<%= link("Continue ->", to: Routes.site_path(@conn, :import_from_google_confirm, @site.domain, view_id: @view_id, access_token: @access_token, refresh_token: @refresh_token, expires_at: @expires_at), class: "button mt-6") %>
<%= link("Continue ->", to: Routes.site_path(@conn, :import_from_google_confirm, @site.domain, view_id: @view_id, access_token: @access_token, refresh_token: @refresh_token, expires_at: @expires_at, legacy: @legacy), class: "button mt-6") %>
</div>

View File

@ -4,6 +4,7 @@
<%= hidden_input(f, :access_token, value: @access_token) %>
<%= hidden_input(f, :refresh_token, value: @refresh_token) %>
<%= hidden_input(f, :expires_at, value: @expires_at) %>
<%= hidden_input(f, :legacy, value: @legacy) %>
<div class="mt-6 text-sm text-gray-500 dark:text-gray-200">
Choose the view in your Google Analytics account that will be imported to the <%= @site.domain %> dashboard.

View File

@ -92,7 +92,7 @@
<% end %>
<PlausibleWeb.Components.Google.button
id="analytics-connect"
to={Plausible.Google.Api.import_authorize_url(@site.id, "import")}
to={Plausible.Google.Api.import_authorize_url(@site.id, "import", true)}
/>
<% end %>
<% else %>

View File

@ -0,0 +1,61 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative border-b border-gray-200 pb-4">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
Import Data
</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Import existing data from external sources. Pick one of the options below to start a new import.
</p>
<PlausibleWeb.Components.Generic.docs_info slug="google-analytics-import" />
</header>
<div class="mt-5 flex gap-x-4">
<PlausibleWeb.Components.Generic.button_link
class="w-36 h-20"
theme="bright"
href={Plausible.Google.Api.import_authorize_url(@site.id, "import", false)}
>
<img src="/images/icon/universal_analytics_logo.svg" alt="New Universal Analytics import" />
</PlausibleWeb.Components.Generic.button_link>
<PlausibleWeb.Components.Generic.button_link
class="w-36 h-20 opacity-40 cursor-not-allowed"
theme="bright"
href=""
>
<img
src="/images/icon/google_analytics_4_logo.svg"
width="110"
alt="New Universal Analytics import"
/>
</PlausibleWeb.Components.Generic.button_link>
<PlausibleWeb.Components.Generic.button_link
class="w-36 h-20 opacity-40 cursor-not-allowed"
theme="bright"
href=""
>
<img class="h-16" src="/images/icon/csv_logo.svg" alt="New CSV import" />
</PlausibleWeb.Components.Generic.button_link>
</div>
<%= live_render(@conn, PlausibleWeb.Live.ImportsExportsSettings,
session: %{"domain" => @site.domain}
) %>
<header class="relative border-b border-gray-200 pb-4">
<h2 class="mt-8 text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
Export Data
</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Export all your data into CSV format.
</p>
</header>
<div class="mt-4">
<PlausibleWeb.Components.Generic.button_link disabled href="">
Export to CSV
</PlausibleWeb.Components.Generic.button_link>
</div>
</div>

View File

@ -59,6 +59,9 @@ defmodule PlausibleWeb.LayoutView do
end,
%{key: "Custom Properties", value: "properties", icon: :document_text},
%{key: "Integrations", value: "integrations", icon: :arrow_path_rounded_square},
if FunWithFlags.enabled?(:imports_exports, for: conn.assigns.site) do
%{key: "Imports & Exports", value: "imports-exports", icon: :arrows_up_down}
end,
%{
key: "Shields",
icon: :shield_exclamation,

View File

@ -10,6 +10,8 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
FunWithFlags.enable(:imports_exports)
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
native_stats_range =
@ -18,9 +20,15 @@ native_stats_range =
Date.utc_today()
)
imported_stats_range =
legacy_imported_stats_range =
Date.range(
Date.add(native_stats_range.first, -360),
Date.add(native_stats_range.first, -180)
)
imported_stats_range =
Date.range(
Date.add(native_stats_range.first, -180),
Date.add(native_stats_range.first, -1)
)
@ -39,7 +47,7 @@ site =
Plausible.Factory.insert(:site,
domain: "dummy.site",
native_stats_start_at: NaiveDateTime.new!(native_stats_range.first, ~T[00:00:00]),
stats_start_date: NaiveDateTime.new!(imported_stats_range.first, ~T[00:00:00]),
stats_start_date: NaiveDateTime.new!(legacy_imported_stats_range.first, ~T[00:00:00]),
memberships: [
Plausible.Factory.build(:site_membership, user: user, role: :owner),
Plausible.Factory.build(:site_membership,
@ -250,13 +258,13 @@ end)
site =
site
|> Plausible.Site.start_import(
imported_stats_range.first,
imported_stats_range.last,
legacy_imported_stats_range.first,
legacy_imported_stats_range.last,
"Google Analytics"
)
|> Plausible.Repo.update!()
imported_stats_range
legacy_imported_stats_range
|> Enum.flat_map(fn date ->
Enum.flat_map(0..Enum.random(1..500), fn _ ->
[
@ -291,3 +299,50 @@ end)
site
|> Plausible.Site.import_success()
|> Plausible.Repo.update!()
site_import =
site
|> Plausible.Imported.SiteImport.create_changeset(user, %{
source: :universal_analytics,
start_date: imported_stats_range.first,
end_date: imported_stats_range.last,
legacy: false
})
|> Plausible.Imported.SiteImport.start_changeset()
|> Plausible.Repo.insert!()
imported_stats_range
|> Enum.flat_map(fn date ->
Enum.flat_map(0..Enum.random(1..500), fn _ ->
[
Plausible.Factory.build(:imported_visitors,
date: date,
pageviews: Enum.random(1..20),
visitors: Enum.random(1..20),
bounces: Enum.random(1..20),
visits: Enum.random(1..200),
visit_duration: Enum.random(1000..10000)
),
Plausible.Factory.build(:imported_sources,
date: date,
source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
visitors: Enum.random(1..20),
visits: Enum.random(1..200),
bounces: Enum.random(1..20),
visit_duration: Enum.random(1000..10000)
),
Plausible.Factory.build(:imported_pages,
date: date,
visitors: Enum.random(1..20),
pageviews: Enum.random(1..20),
exits: Enum.random(1..20),
time_on_page: Enum.random(1000..10000)
)
]
end)
end)
|> then(&Plausible.TestUtils.populate_stats(site, site_import.id, &1))
site_import
|> Plausible.Imported.SiteImport.complete_changeset()
|> Plausible.Repo.update!()

View File

@ -0,0 +1 @@
<svg height="64" viewBox="0 0 56 64" width="56" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m5.106 0c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill="#45b058" fill-rule="evenodd"/><path d="m20.306 43.197c.126.144.198.324.198.522 0 .378-.306.72-.703.72-.18 0-.378-.072-.504-.234-.702-.846-1.891-1.387-3.007-1.387-2.629 0-4.627 2.017-4.627 4.88 0 2.845 1.999 4.879 4.627 4.879 1.134 0 2.25-.486 3.007-1.369.125-.144.324-.233.504-.233.415 0 .703.359.703.738 0 .18-.072.36-.198.504-.937.972-2.215 1.693-4.015 1.693-3.457 0-6.176-2.521-6.176-6.212s2.719-6.212 6.176-6.212c1.8.001 3.096.721 4.015 1.711zm6.802 10.714c-1.782 0-3.187-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.361.27-.757.702-.757.144 0 .306.036.432.144.828.739 1.98 1.314 3.367 1.314 2.143 0 2.827-1.152 2.827-2.071 0-3.097-7.112-1.386-7.112-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.467 3.853 1.278.162.144.252.342.252.54 0 .36-.306.72-.703.72-.144 0-.306-.054-.432-.162-.882-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636.001 1.748-1.187 3.531-4.428 3.531zm16.994-11.254-4.159 10.335c-.198.486-.685.81-1.188.81h-.036c-.522 0-1.008-.324-1.207-.81l-4.142-10.335c-.036-.09-.054-.18-.054-.288 0-.36.323-.793.81-.793.306 0 .594.18.72.486l3.889 9.992 3.889-9.992c.108-.288.396-.486.72-.486.468 0 .81.378.81.793.001.09-.017.198-.052.288z" fill="#fff"/><g clip-rule="evenodd" fill-rule="evenodd"><path d="m56.001 20.357v1h-12.8s-6.312-1.26-6.128-6.707c0 0 .208 5.707 6.003 5.707z" fill="#349c42"/><path d="m37.098.006v14.561c0 1.656 1.104 5.791 6.104 5.791h12.8l-18.904-20.352z" fill="#fff" opacity=".5"/></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="26 -29 120 60" width="120" height="60"><linearGradient id="A" gradientUnits="userSpaceOnUse" x1="173.867" y1="-72.65" x2="216.749" y2="-72.65"><stop offset="0" stop-color="#e96f0b"/><stop offset="1" stop-color="#f37901"/></linearGradient><g fill-rule="evenodd"><path d="M47.33 16H36.226c-1.364 0-2.468-1.104-2.468-2.468V8.208c0-1.364 1.104-2.468 2.468-2.468h7.013v-7.792c0-1.364 1.104-2.468 2.468-2.468H53.5v20.455h-6.17z" fill="#ffc517"/><path d="M61.3 16h-7.8v-27.532c0-1.364 1.104-2.468 2.468-2.468h5.325c1.364 0 2.468 1.104 2.468 2.468v25.065C63.693 14.896 62.6 16 61.3 16z" fill="#f57e02"/><path d="M216.708-72.65v32.646c0 5.713-4.625 10.338-10.338 10.338h-32.646v-85.968l42.984 42.984z" fill="url(#A)" transform="matrix(.238687 0 0 .238687 11.968065 23.081216)"/></g><g fill="#757575"><path d="M74.093 7.228l2.388 6.27h-4.627l2.24-6.27zm-.896-2.1l-5.224 13.73h1.94l1.343-3.73h5.82l1.343 3.73h1.94l-5.224-13.73h-1.94zm28.2 13.742h1.8V5.138h-1.8v13.73zm-18.35-8.07c.597-.896 1.8-1.642 2.985-1.642 2.388 0 3.582 1.642 3.582 4.03v5.82h-1.8v-5.522c0-1.94-1.045-2.687-2.388-2.687-1.493 0-2.537 1.493-2.537 2.836v5.224h-1.8V9.32h1.8l.15 1.493zm8.06 5.234c0-2.1 1.94-3.284 4.18-3.284 1.343 0 2.24.3 2.537.597v-.3c0-1.493-1.194-2.24-2.388-2.24-1.045 0-1.94.448-2.24 1.343l-1.642-.746c.3-.896 1.493-2.24 3.88-2.24 2.24 0 4.18 1.343 4.18 4.03v5.672h-1.642v-1.343h-.15c-.448.746-1.493 1.642-3.134 1.642-1.94 0-3.582-1.194-3.582-3.134m6.716-1.194s-.746-.597-2.24-.597c-1.8 0-2.537 1.045-2.537 1.8 0 1.045 1.045 1.493 1.94 1.493 1.343 0 2.836-1.194 2.836-2.687"/><path d="M105.735 23.05l2.1-4.925-3.73-8.657h1.8l2.836 6.567 2.836-6.567h1.8l-5.82 13.582h-1.8zm26.27-10.45c-.448-1.194-1.493-1.94-2.537-1.94-1.493 0-2.836 1.343-2.836 3.284s1.343 3.284 2.836 3.284c1.045 0 2.1-.746 2.537-1.8l1.493.896c-.746 1.642-2.24 2.687-4.03 2.687-2.537 0-4.627-2.24-4.627-5.075 0-2.985 2.1-5.075 4.627-5.075 1.8 0 3.284 1.045 4.03 2.687l-1.493 1.045z" fill-rule="evenodd"/><path d="M138.422 19.168c2.388 0 3.582-1.343 3.582-2.985 0-3.582-5.224-2.24-5.224-4.328 0-.746.597-1.194 1.642-1.194s2.1.448 2.537 1.194l1.045-1.045c-.597-.746-2.24-1.8-3.73-1.8-2.24 0-3.433 1.343-3.433 2.985 0 3.433 5.373 2.24 5.373 4.03 0 .896-.597 1.493-1.8 1.493s-1.8-.746-2.388-1.493L134.7 17.08c.896.896 2.24 2.1 3.73 2.1zm-17.015-.298h1.8V9.318h-1.8v9.552z"/><path d="M122.153 4.7c.746 0 1.194.597 1.194 1.194 0 .746-.597 1.194-1.194 1.194s-1.194-.597-1.194-1.194.597-1.194 1.194-1.194zm-2.537 12.687l.3 1.343h-1.8c-1.642 0-2.388-1.194-2.388-2.985V11.1h-1.8V9.3h1.8V6.482h1.8v2.836h2.1v1.8h-2.1v4.925c0 1.343 1.045 1.343 2.1 1.343z" fill-rule="evenodd"/><path d="M77.048-10.042v2.24h5.224c-.15 1.194-.597 2.1-1.194 2.687-.746.746-1.94 1.642-4.03 1.642-3.134 0-5.672-2.537-5.672-5.82 0-3.134 2.537-5.82 5.672-5.82 1.8 0 2.985.746 3.88 1.493l1.493-1.493c-1.343-1.194-2.985-2.24-5.373-2.24-4.328 0-8.06 3.582-8.06 7.9s3.73 7.9 8.06 7.9c2.388 0 4.18-.746 5.522-2.24 1.493-1.493 1.94-3.433 1.94-5.075 0-.448 0-1.045-.15-1.343h-7.313zm13.282-1.8c-2.836 0-5.075 2.1-5.075 5.075s2.24 5.075 5.075 5.075a5.03 5.03 0 0 0 5.075-5.075c0-2.985-2.24-5.075-5.075-5.075zm0 8.2c-1.493 0-2.836-1.343-2.836-3.134S88.837-9.9 90.33-9.9s2.837 1.2 2.837 3.13-1.343 3.134-2.836 3.134zm24.47-7.156c-.597-.597-1.493-1.194-2.836-1.194-2.537 0-4.776 2.24-4.776 5.075s2.24 5.075 4.776 5.075c1.194 0 2.24-.597 2.687-1.194h.15v.746c0 1.94-1.045 2.985-2.687 2.985-1.343 0-2.24-1.045-2.537-1.8l-1.94.746c.597 1.343 2.1 2.985 4.478 2.985 2.687 0 4.925-1.493 4.925-5.373V-12h-2.1v1.194zm-2.537 7.164c-1.493 0-2.687-1.343-2.687-3.134s1.194-3.134 2.687-3.134 2.687 1.343 2.687 3.134-1.194 3.134-2.687 3.134zm-10.897-8.2c-2.836 0-5.075 2.1-5.075 5.075s2.24 5.075 5.075 5.075a5.03 5.03 0 0 0 5.075-5.075c.15-2.985-2.24-5.075-5.075-5.075zm0 8.2c-1.493 0-2.836-1.343-2.836-3.134s1.343-3.134 2.836-3.134 2.836 1.194 2.836 3.134-1.343 3.134-2.836 3.134zM118.7-17.206h2.24v15.522h-2.24v-15.522zm8.358 13.582c-1.194 0-1.94-.597-2.537-1.493l6.866-2.836-.3-.597c-.448-1.194-1.8-3.284-4.328-3.284-2.687 0-4.776 2.1-4.776 5.075a5.03 5.03 0 0 0 5.075 5.075c2.388 0 3.73-1.493 4.328-2.24l-1.8-1.194c-.597.896-1.343 1.493-2.537 1.493zm-.15-6.27c.896 0 1.642.448 1.94 1.194l-4.627 1.94c0-2.24 1.493-3.134 2.687-3.134z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -20,7 +20,8 @@ defmodule Plausible.Imported.UniversalAnalyticsTest do
end_date: "2024-01-02",
access_token: "access123",
refresh_token: "refresh123",
token_expires_at: expires_at
token_expires_at: expires_at,
legacy: true
)
assert %Oban.Job{
@ -42,7 +43,8 @@ defmodule Plausible.Imported.UniversalAnalyticsTest do
source: :universal_analytics,
start_date: ~D[2023-10-01],
end_date: ~D[2024-01-02],
status: SiteImport.pending()
status: SiteImport.pending(),
legacy: true
}
] = Plausible.Imported.list_all_imports(site)
@ -61,6 +63,35 @@ defmodule Plausible.Imported.UniversalAnalyticsTest do
assert opts[:date_range] == Date.range(~D[2023-10-01], ~D[2024-01-02])
assert opts[:auth] == {"access123", "refresh123", expires_at}
end
test "creates SiteImport with legacy flag set to false when instructed", %{
user: user,
site: site
} do
expires_at = NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
assert {:ok, job} =
UniversalAnalytics.new_import(site, user,
view_id: 123,
start_date: "2023-10-01",
end_date: "2024-01-02",
access_token: "access123",
refresh_token: "refresh123",
token_expires_at: expires_at,
legacy: false
)
assert %Oban.Job{args: %{"import_id" => import_id}} = Repo.reload!(job)
assert [
%{
id: ^import_id,
legacy: false
}
] = Plausible.Imported.list_all_imports(site)
assert %{imported_data: nil} = Repo.reload!(site)
end
end
describe "import_data/2" do

View File

@ -0,0 +1,62 @@
defmodule Plausible.ImportedTest do
use Plausible.DataCase
use Plausible
alias Plausible.Imported
describe "list_all_imports/1" do
test "returns empty when there are no imports" do
site = insert(:site)
assert Imported.list_all_imports(site) == []
end
test "returns imports in various states" do
site = insert(:site)
_rogue_import = insert(:site_import)
import1 = insert(:site_import, site: site, status: :pending)
import2 = insert(:site_import, site: site, status: :importing)
import3 = insert(:site_import, site: site, status: :completed)
import4 = insert(:site_import, site: site, status: :failed)
assert [%{id: id1}, %{id: id2}, %{id: id3}, %{id: id4}] = Imported.list_all_imports(site)
ids = [id1, id2, id3, id4]
assert import1.id in ids
assert import2.id in ids
assert import3.id in ids
assert import4.id in ids
end
test "returns one legacy import when present with respective site import entry" do
site = insert(:site)
{:ok, opts} = add_imported_data(%{site: site})
site = Map.new(opts).site
site_import = insert(:site_import, site: site, legacy: true)
site_import_id = site_import.id
assert [%{id: ^site_import_id}] = Imported.list_all_imports(site)
end
test "returns legacy import without respective site import entry" do
site = insert(:site)
{:ok, opts} = add_imported_data(%{site: site})
site = Map.new(opts).site
imported_start_date = site.imported_data.start_date
imported_end_date = site.imported_data.end_date
assert [
%{
id: 0,
source: :universal_analytics,
start_date: ^imported_start_date,
end_date: ^imported_end_date,
status: :completed
}
] = Imported.list_all_imports(site)
end
end
end

View File

@ -89,6 +89,69 @@ defmodule Plausible.PurgeTest do
end)
end
test "delete_imported_stats!/2 deletes legacy imported data only when instructed", %{
site: site,
site_import1: site_import1,
site_import2: site_import2
} do
populate_stats(site, [
build(:pageview),
build(:imported_visitors),
build(:imported_sources),
build(:imported_pages),
build(:imported_entry_pages),
build(:imported_exit_pages),
build(:imported_locations),
build(:imported_devices),
build(:imported_browsers),
build(:imported_operating_systems)
])
Enum.each(Plausible.Imported.tables(), fn table ->
query =
from(imported in table,
where: imported.site_id == ^site.id and imported.import_id == ^site_import1.id
)
assert await_clickhouse_count(query, 1)
query =
from(imported in table,
where: imported.site_id == ^site.id and imported.import_id == ^site_import2.id
)
assert await_clickhouse_count(query, 1)
query =
from(imported in table, where: imported.site_id == ^site.id and imported.import_id == 0)
assert await_clickhouse_count(query, 1)
end)
assert :ok == Plausible.Purge.delete_imported_stats!(site, 0)
Enum.each(Plausible.Imported.tables(), fn table ->
query =
from(imported in table,
where: imported.site_id == ^site.id and imported.import_id == ^site_import1.id
)
assert await_clickhouse_count(query, 1)
query =
from(imported in table,
where: imported.site_id == ^site.id and imported.import_id == ^site_import2.id
)
assert await_clickhouse_count(query, 1)
query =
from(imported in table, where: imported.site_id == ^site.id and imported.import_id == 0)
assert await_clickhouse_count(query, 0)
end)
end
test "delete_imported_stats!/1 resets stats_start_date", %{site: site} do
assert :ok == Plausible.Purge.delete_imported_stats!(site)
assert %Plausible.Site{stats_start_date: nil} = Plausible.Repo.reload(site)

View File

@ -46,7 +46,7 @@ defmodule Plausible.SitesTest do
assert Sites.stats_start_date(site) == nil
end
test "is date if first pageview if site does have stats" do
test "is date if site does have stats" do
site = insert(:site)
populate_stats(site, [
@ -70,6 +70,33 @@ defmodule Plausible.SitesTest do
end
end
describe "native_stats_start_date" do
test "is nil if site has no stats" do
site = insert(:site)
assert Sites.native_stats_start_date(site) == nil
end
test "is date if site does have stats" do
site = insert(:site)
populate_stats(site, [
build(:pageview)
])
assert Sites.native_stats_start_date(site) == Timex.today(site.timezone)
end
test "ignores imported stats" do
site = insert(:site)
insert(:site_import, site: site)
{:ok, opts} = add_imported_data(%{site: site})
site = Map.new(opts).site
assert Sites.native_stats_start_date(site) == nil
end
end
describe "has_stats?" do
test "is false if site has no stats" do
site = insert(:site)

View File

@ -254,4 +254,38 @@ defmodule Plausible.Stats.ClickhouseTest do
] = Clickhouse.top_sources_for_spike(site, query, 5, 1)
end
end
describe "imported_pageview_counts/1" do
test "gets pageview counts for each of sites' imports" do
site = insert(:site)
{:ok, opts} = add_imported_data(%{site: site})
site = Map.new(opts).site
import1 = insert(:site_import, site: site)
import2 = insert(:site_import, site: site)
# legacy import
populate_stats(site, [
build(:imported_visitors, pageviews: 5),
build(:imported_visitors, pageviews: 6)
])
populate_stats(site, import1.id, [
build(:imported_visitors, pageviews: 6),
build(:imported_visitors, pageviews: 8)
])
populate_stats(site, import2.id, [
build(:imported_visitors, pageviews: 7),
build(:imported_visitors, pageviews: 13)
])
pageview_counts = Clickhouse.imported_pageview_counts(site)
assert map_size(pageview_counts) == 3
assert pageview_counts[0] == 11
assert pageview_counts[import1.id] == 14
assert pageview_counts[import2.id] == 20
end
end
end

View File

@ -1,4 +1,4 @@
defmodule Plausible.ImportedTest do
defmodule PlausibleWeb.Api.StatsController.ImportedTest do
use PlausibleWeb.ConnCase
use Timex

View File

@ -1483,7 +1483,8 @@ defmodule PlausibleWeb.AuthControllerTest do
callback_params = %{"error" => "access_denied", "state" => "[#{site.id},\"import\"]"}
conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params)
assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_general, site.domain)
assert redirected_to(conn, 302) ==
Routes.site_path(conn, :settings_integrations, site.domain)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"unable to authenticate your Google Analytics"

View File

@ -636,6 +636,46 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "GET /:website/settings/imports-exports" do
setup [:create_user, :log_in, :create_site]
test "renders empty imports list", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/imports-exports")
resp = html_response(conn, 200)
assert text_of_attr(resp, ~s|a[href]|, "href") =~
"https://accounts.google.com/o/oauth2/"
assert resp =~ "Import Data"
assert resp =~ "Existing Imports"
assert resp =~ "There are no imports yet"
assert resp =~ "Export Data"
end
test "renders imports in import list", %{conn: conn, site: site} do
{:ok, opts} = add_imported_data(%{site: site})
site = Map.new(opts).site
_site_import1 = insert(:site_import, site: site, status: SiteImport.pending())
_site_import2 = insert(:site_import, site: site, status: SiteImport.importing())
site_import3 = insert(:site_import, site: site, status: SiteImport.completed())
_site_import4 = insert(:site_import, site: site, status: SiteImport.failed())
populate_stats(site, site_import3.id, [
build(:imported_visitors, pageviews: 77),
build(:imported_visitors, pageviews: 21)
])
conn = get(conn, "/#{site.domain}/settings/imports-exports")
resp = html_response(conn, 200)
buttons = find(resp, ~s|button[data-method="delete"]|)
assert length(buttons) == 5
assert resp =~ "(98 page views)"
end
end
describe "GET /:website/settings/integrations for self-hosting" do
setup [:create_user, :log_in, :create_site]
@ -1252,7 +1292,8 @@ defmodule PlausibleWeb.SiteControllerTest do
|> get("/#{site.domain}/import/google-analytics/view-id", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
"expires_at" => "2022-09-22T20:01:37.112777",
"legacy" => "true"
})
|> html_response(200)
@ -1271,7 +1312,8 @@ defmodule PlausibleWeb.SiteControllerTest do
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
"expires_at" => "2022-09-22T20:01:37.112777",
"legacy" => "true"
})
[site_import] = Plausible.Imported.list_all_imports(site)
@ -1288,10 +1330,11 @@ defmodule PlausibleWeb.SiteControllerTest do
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
"expires_at" => "2022-09-22T20:01:37.112777",
"legacy" => "true"
})
assert [%{id: import_id}] = Plausible.Imported.list_all_imports(site)
assert [%{id: import_id, legacy: true}] = Plausible.Imported.list_all_imports(site)
assert_enqueued(
worker: Plausible.Workers.ImportAnalytics,
@ -1308,8 +1351,81 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "DELETE /:website/settings/:forget_imported" do
setup [:create_user, :log_in, :create_new_site]
describe "DELETE /:website/settings/:forget_import/:import_id" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
test "removes site import, associated data and cancels oban job for a particular import", %{
conn: conn,
user: user,
site: site
} do
{:ok, job} =
Plausible.Imported.NoopImporter.new_import(
site,
user,
start_date: ~D[2022-01-01],
end_date: Timex.today()
)
%{args: %{import_id: import_id}} = job
# legacy stats
populate_stats(site, [
build(:imported_visitors, pageviews: 12)
])
populate_stats(site, import_id, [
build(:imported_visitors, pageviews: 10)
])
assert [%{id: ^import_id}, %{id: 0}] = Plausible.Imported.list_all_imports(site)
assert eventually(fn ->
count = Plausible.Stats.Clickhouse.imported_pageview_count(site)
{count == 22, count}
end)
delete(conn, "/#{site.domain}/settings/forget-import/#{import_id}")
assert eventually(fn ->
count = Plausible.Stats.Clickhouse.imported_pageview_count(site)
{count == 12, count}
end)
assert Repo.reload(job).state == "cancelled"
end
test "removes legacy site import along with associated data when instructed", %{
conn: conn,
site: site
} do
other_site_import = insert(:site_import, site: site)
# legacy stats
populate_stats(site, [
build(:imported_visitors, pageviews: 12)
])
populate_stats(site, other_site_import.id, [
build(:imported_visitors, pageviews: 10)
])
assert eventually(fn ->
count = Plausible.Stats.Clickhouse.imported_pageview_count(site)
{count == 22, count}
end)
delete(conn, "/#{site.domain}/settings/forget-import/0")
assert eventually(fn ->
count = Plausible.Stats.Clickhouse.imported_pageview_count(site)
{count == 10, count}
end)
end
end
describe "DELETE /:website/settings/forget_imported" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
test "removes imported_data field from site", %{conn: conn, site: site} do
delete(conn, "/#{site.domain}/settings/forget-imported")

View File

@ -50,6 +50,20 @@ defmodule Plausible.Factory do
}
end
def site_import_factory do
today = Date.utc_today()
%Plausible.Imported.SiteImport{
site: build(:site),
imported_by: build(:user),
start_date: Date.add(today, -200),
end_date: today,
source: :universal_analytics,
status: :completed,
legacy: false
}
end
def ch_session_factory do
hostname = sequence(:domain, &"example-#{&1}.com")

View File

@ -2,6 +2,7 @@
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
Application.ensure_all_started(:double)
FunWithFlags.enable(:window_time_on_page)
FunWithFlags.enable(:imports_exports)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
if Mix.env() == :small_test do

View File

@ -229,5 +229,26 @@ defmodule Plausible.Workers.ImportAnalyticsTest do
assert_receive {:notification, :analytics_imports_jobs, %{"transient_fail" => ^import_id}}
end)
end
test "sends oban notification to calling process on completion when listener setup separately",
%{
import_opts: import_opts
} do
Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn ->
user = insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: 1))
site = insert(:site, members: [user])
{:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts)
import_id = job.args[:import_id]
:ok = Plausible.Imported.Importer.listen()
job
|> Repo.reload!()
|> ImportAnalytics.perform()
assert_receive {:notification, :analytics_imports_jobs, %{"complete" => ^import_id}}
end)
end
end
end