Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ config :logger, :default_formatter,
:mode,
:phase,
:reason,
:state,
:photo_id,
:journal_entry_id,
:issue_codes,
:details,
:x,
Expand Down
35 changes: 34 additions & 1 deletion lib/gtfs_planner/gtfs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule GtfsPlanner.Gtfs do
alias GtfsPlanner.Gtfs.FloorplanTransform
alias GtfsPlanner.Gtfs.Frequency
alias GtfsPlanner.Gtfs.Level
alias GtfsPlanner.Gtfs.JournalEntry
alias GtfsPlanner.Gtfs.Location
alias GtfsPlanner.Gtfs.Network
alias GtfsPlanner.Gtfs.Pathway
Expand All @@ -36,6 +37,8 @@ defmodule GtfsPlanner.Gtfs do
alias GtfsPlanner.Gtfs.RoutePattern
alias GtfsPlanner.Gtfs.Shape
alias GtfsPlanner.Gtfs.StationEditingStatus
alias GtfsPlanner.Gtfs.StationJournal
alias GtfsPlanner.Gtfs.StationJournal.Scope
alias GtfsPlanner.Gtfs.StationNaming
alias GtfsPlanner.Gtfs.Stop
alias GtfsPlanner.Gtfs.StopArea
Expand All @@ -61,6 +64,34 @@ defmodule GtfsPlanner.Gtfs do
location_type: 0 | 1 | 2 | 3 | 4 | String.t() | nil
]

@spec resolve_station_journal_scope(Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t()) ::
{:ok, Scope.t()} | {:error, :not_found | :invalid_id}
def resolve_station_journal_scope(organization_id, gtfs_version_id, station_id, actor_id),
do: StationJournal.resolve_scope(organization_id, gtfs_version_id, station_id, actor_id)

@spec sync_journal_entries(Scope.t(), [map()]) :: %{
synced_count: non_neg_integer(),
errors: [map()]
}
def sync_journal_entries(%Scope{} = scope, entries),
do: StationJournal.sync_entries(scope, entries)

@spec list_station_journal(Scope.t()) :: [JournalEntry.t()]
def list_station_journal(%Scope{} = scope), do: StationJournal.list_entries(scope)

@spec create_journal_photo(
Scope.t(),
map(),
%{path: String.t(), filename: String.t(), content_type: String.t() | nil}
) :: {:ok, GtfsPlanner.Gtfs.JournalPhoto.t()} | {:error, atom() | Ecto.Changeset.t()}
def create_journal_photo(%Scope{} = scope, attrs, upload),
do: StationJournal.create_photo(scope, attrs, upload)

@spec refresh_pin_coordinates_for_stop_level(StopLevel.t(), pos_integer(), pos_integer()) ::
{:ok, non_neg_integer()} | {:error, term()}
def refresh_pin_coordinates_for_stop_level(%StopLevel{} = stop_level, image_w, image_h),
do: StationJournal.refresh_pin_coordinates_for_stop_level(stop_level, image_w, image_h)

@doc """
Returns the list of routes for an organization and GTFS version.

Expand Down Expand Up @@ -594,7 +625,9 @@ defmodule GtfsPlanner.Gtfs do
|> Repo.update(),
{:ok, active_derived} <-
derive_child_stop_coords(updated_stop_level, image_w, image_h),
{:ok, active_updated_stops} <- persist_derived_coords_in_tx(active_derived) do
{:ok, active_updated_stops} <- persist_derived_coords_in_tx(active_derived),
{:ok, _refreshed_pin_count} <-
refresh_pin_coordinates_for_stop_level(updated_stop_level, image_w, image_h) do
Logger.debug(fn ->
"[ALIGN_APPLY_DEBUG] active_stop_level_persisted " <>
inspect(%{
Expand Down
148 changes: 148 additions & 0 deletions lib/gtfs_planner/gtfs/journal_entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
defmodule GtfsPlanner.Gtfs.JournalEntry do
use Ecto.Schema

import Ecto.Changeset

alias GtfsPlanner.Gtfs.StationJournal.Scope

@target_types ~w(station node pathway pin)
@primary_key {:id, :binary_id, autogenerate: false}
@foreign_key_type :binary_id

@type t :: %__MODULE__{
id: Ecto.UUID.t(),
organization_id: Ecto.UUID.t(),
gtfs_version_id: Ecto.UUID.t(),
station_id: Ecto.UUID.t(),
author_id: Ecto.UUID.t(),
target_type: String.t(),
target_id: Ecto.UUID.t() | nil,
stop_level_id: Ecto.UUID.t() | nil,
diagram_x: float() | nil,
diagram_y: float() | nil,
body: String.t() | nil,
captured_at: DateTime.t(),
closed_at: DateTime.t() | nil,
closed_by: Ecto.UUID.t() | nil,
lat: float() | nil,
lon: float() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

schema "journal_entries" do
belongs_to :organization, GtfsPlanner.Organizations.Organization
belongs_to :gtfs_version, GtfsPlanner.Versions.GtfsVersion
belongs_to :station, GtfsPlanner.Gtfs.Stop

field :author_id, :binary_id
field :target_type, :string
field :target_id, :binary_id
field :stop_level_id, :binary_id
field :diagram_x, :float
field :diagram_y, :float
field :body, :string
field :captured_at, :utc_datetime_usec
field :closed_at, :utc_datetime_usec
field :closed_by, :binary_id
field :lat, :float
field :lon, :float

has_many :photos, GtfsPlanner.Gtfs.JournalPhoto

timestamps(type: :utc_datetime_usec)
end

@spec create_changeset(t(), map(), Scope.t()) :: Ecto.Changeset.t()
def create_changeset(entry, client_attrs, %Scope{} = scope) do
entry
|> cast(client_attrs, [
:id,
:target_type,
:target_id,
:stop_level_id,
:diagram_x,
:diagram_y,
:body,
:captured_at
])
|> put_change(:organization_id, scope.organization_id)
|> put_change(:gtfs_version_id, scope.gtfs_version_id)
|> put_change(:station_id, scope.station_id)
|> put_change(:author_id, scope.actor_id)
|> validate_entry_fields()
end

@spec sync_changeset(t(), map()) :: Ecto.Changeset.t()
def sync_changeset(entry, client_attrs) do
entry
|> cast(client_attrs, [
:target_type,
:target_id,
:stop_level_id,
:diagram_x,
:diagram_y,
:body
])
|> validate_entry_fields(required: false)
end

@spec derived_coordinates_changeset(t(), %{lat: float() | nil, lon: float() | nil}) ::
Ecto.Changeset.t()
def derived_coordinates_changeset(entry, attrs) do
entry
|> cast(attrs, [:lat, :lon])
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
end

defp validate_entry_fields(changeset, options \\ []) do
required =
if Keyword.get(options, :required, true), do: [:id, :target_type, :captured_at], else: []

changeset
|> validate_required(required)
|> validate_inclusion(:target_type, @target_types)
|> validate_target_shape()
|> check_constraint(:target_type, name: :journal_entries_target_shape_ck)
|> check_constraint(:closed_at, name: :journal_entries_closure_pair_ck)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:gtfs_version_id)
|> foreign_key_constraint(:station_id)
end

defp validate_target_shape(changeset) do
target_type = get_field(changeset, :target_type)
target_id = get_field(changeset, :target_id)
stop_level_id = get_field(changeset, :stop_level_id)
diagram_x = get_field(changeset, :diagram_x)
diagram_y = get_field(changeset, :diagram_y)

valid? =
case target_type do
"station" ->
is_nil(target_id) and is_nil(stop_level_id) and is_nil(diagram_x) and is_nil(diagram_y)

type when type in ["node", "pathway"] ->
not is_nil(target_id) and is_nil(stop_level_id) and is_nil(diagram_x) and
is_nil(diagram_y)

"pin" ->
is_nil(target_id) and not is_nil(stop_level_id) and finite_non_negative?(diagram_x) and
finite_non_negative?(diagram_y)

_ ->
true
end

if valid?,
do: changeset,
else: add_error(changeset, :target_type, "has an invalid target shape")
end

defp finite_non_negative?(value) when is_integer(value), do: value >= 0

defp finite_non_negative?(value) when is_float(value), do: value >= 0

defp finite_non_negative?(_), do: false
end
74 changes: 74 additions & 0 deletions lib/gtfs_planner/gtfs/journal_photo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule GtfsPlanner.Gtfs.JournalPhoto do
use Ecto.Schema

import Ecto.Changeset

@content_types ~w(image/jpeg image/png)
@primary_key {:id, :binary_id, autogenerate: false}
@foreign_key_type :binary_id

@type t :: %__MODULE__{
id: Ecto.UUID.t(),
journal_entry_id: Ecto.UUID.t(),
filename: String.t(),
content_type: String.t(),
byte_size: pos_integer(),
sha256: binary(),
width: pos_integer() | nil,
height: pos_integer() | nil,
captured_at: DateTime.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

schema "journal_photos" do
belongs_to :journal_entry, GtfsPlanner.Gtfs.JournalEntry

field :filename, :string
field :content_type, :string
field :byte_size, :integer
field :sha256, :binary
field :width, :integer
field :height, :integer
field :captured_at, :utc_datetime_usec

timestamps(type: :utc_datetime_usec)
end

@spec create_changeset(t(), map()) :: Ecto.Changeset.t()
def create_changeset(photo, trusted_attrs) do
photo
|> cast(trusted_attrs, [
:id,
:journal_entry_id,
:filename,
:content_type,
:byte_size,
:sha256,
:width,
:height,
:captured_at
])
|> validate_required([
:id,
:journal_entry_id,
:filename,
:content_type,
:byte_size,
:sha256,
:captured_at
])
|> validate_inclusion(:content_type, @content_types)
|> validate_number(:byte_size, greater_than: 0)
|> validate_number(:width, greater_than: 0)
|> validate_number(:height, greater_than: 0)
|> validate_change(:sha256, fn :sha256, digest ->
if is_binary(digest) and byte_size(digest) == 32, do: [], else: [sha256: "must be 32 bytes"]
end)
|> check_constraint(:content_type, name: :journal_photos_content_type_ck)
|> check_constraint(:byte_size, name: :journal_photos_byte_size_positive_ck)
|> check_constraint(:width, name: :journal_photos_dimensions_positive_ck)
|> check_constraint(:sha256, name: :journal_photos_sha256_length_ck)
|> foreign_key_constraint(:journal_entry_id)
end
end
Loading