diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
new file mode 100644
index 0000000..b3c1e74
--- /dev/null
+++ b/.github/workflows/elixir.yml
@@ -0,0 +1,59 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ services:
+ postgres:
+ image: postgres:13
+ ports:
+ - 5432:5432
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: ppr_api_test
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ strategy:
+ matrix:
+ elixir: [1.13.4, 1.14.3]
+ otp: [24.3, 25.1]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Set up Elixir
+ uses: erlef/setup-beam@v1
+ with:
+ elixir-version: ${{ matrix.elixir }}
+ otp-version: ${{ matrix.otp }}
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Set up the database
+ run: |
+ mix ecto.create
+ mix ecto.migrate
+ env:
+ MIX_ENV: test
+ DATABASE_URL: ecto://postgres:postgres@localhost/ppr_api_test
+
+ - name: Run tests
+ run: mix test
+ env:
+ MIX_ENV: test
diff --git a/README.md b/README.md
index ab1e36d..61ea248 100644
--- a/README.md
+++ b/README.md
@@ -6,15 +6,13 @@ There are a bunch of caveats about the data on the Go ahead.
-I made a public site primarily to illustrate how one might interact with this API. It’s generally what you'd expect, I hope. GET /api/sales will return a list of JSON objects. GET /api/sales/:id will return a single JSON sale object. The :id is a uuid, so it's not guessable – you'll need to GET /api/sales to find them.
-
-At the moment, the list endpoint only takes before or after params to page around — no filtering or sorting yet. If you want to do that, you can page through the sales and ingest them into your own database. And then go wild. You do you.
+I made a public site primarily to illustrate how one might interact with this API. It’s generally what you'd expect, I hope. GET /api/v1/residential/sales will return a list of JSON objects.
## development
-It's pretty straightforward to run the app yourself. It's a typical [Elixir](https://elixir-lang.org)/[Phoenix](https://www.phoenixframework.org) app with a GenServer for polling and consuming the CSVs from the PSRA.
+It's pretty straightforward to run the app yourself. It's a typical [Elixir](https://elixir-lang.org)/[Phoenix](https://www.phoenixframework.org) app that fetches and consumes CSVs from the PSRA.
-You'll need Elixir and PostgreSQL installed.
+You'll need Elixir, Erlang and PostgreSQL installed.
```
git clone https://github.com/civictech-ie/price-register.git
@@ -24,14 +22,10 @@ mix ecto.setup
mix phx.server
```
-Now you can visit [`localhost:4000`](http://localhost:4000) and it should be working.
-
-## deployment
-
-It's deployed at [Render](https://www.render.com) and I'll keep an eye to make sure it can handle the load the API is getting. If you're planning on making a ton of requests (eg querying directly from a popular client-side app), you might give me a heads up, or, better yet, set up your own deployment. It's very easy...
+Now you can visit [`localhost:4000`](http://localhost:4000) and it should be working. It might run a bit hot while it does its initial fetch from the PSRA.
## contributing
If you're interested in contributing, put a note [in the issues](https://github.com/civictech-ie/price-register/issues). And make sure the tests pass (`mix test`).
-In general, I'm thinking the scope is: let's make the API a bit better for querying and ordering results. But making the public site much better is out of scope – if you want to do that, just make your own service that consumes this API!
\ No newline at end of file
+In general, I'm thinking the scope is: let's make the API a bit better for querying and ordering results. But making the public site much better is out of scope – if you want to do that, just make your own service that consumes this API!
diff --git a/assets/css/app.css b/assets/css/app.css
index b8b3d03..378c8f9 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,133 +1,5 @@
-/* This file is for your main application CSS */
-
-// reset
-
-@viewport {
- zoom: 1;
- width: extend-to-zoom;
-}
-
-@-ms-viewport {
- width: extend-to-zoom;
- zoom: 1;
-}
-
-html,
-body,
-div,
-span,
-applet,
-object,
-iframe,
-h1,
-h2,
-h3,
-h4,
-h5,
-h6,
-p,
-blockquote,
-pre,
-a,
-abbr,
-acronym,
-address,
-big,
-cite,
-code,
-del,
-dfn,
-font,
-img,
-ins,
-kbd,
-q,
-s,
-samp,
-small,
-strike,
-sub,
-sup,
-tt,
-var,
-b,
-u,
-i,
-center,
-dl,
-dt,
-dd,
-ol,
-ul,
-li,
-fieldset,
-form,
-label,
-legend,
-table,
-caption,
-tbody,
-tfoot,
-thead,
-tr,
-th,
-td {
- margin: 0;
- padding: 0;
- font-size: 100%;
- vertical-align: baseline;
- border: 0;
- background: transparent;
- font-feature-settings: "kern", "liga", "pnum";
- -webkit-backface-visibility: visible;
- -webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
- webkit-text-size-adjust: 100%;
- -ms-text-size-adjust: 100%;
- text-size-adjust: 100%;
-}
-
-blockquote,
-q {
- quotes: none;
-}
-
-table {
- border-collapse: collapse;
- border-spacing: 0;
-}
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
-ul,
-ol {
- list-style: none;
-}
-
-* {
- -moz-box-sizing: border-box;
- -webkit-box-sizing: border-box;
- -ms-box-sizing: border-box;
- box-sizing: border-box;
-}
-
-// styles
-
-html {
- font-size: 10px;
- --accent: #ff4400;
- --background: #fafafa;
- --ink: #000;
- --offset: #eee;
-}
-
-body {
- background-color: var(--background);
- color: var(--ink);
- font-size: 1.1rem;
- line-height: 2rem;
- font-family: "Inter", serif;
-}
-
-::selection {
- color: var(--accent);
- background: var(--offset);
-}
\ No newline at end of file
+/* This file is for your main application CSS */
diff --git a/assets/js/app.js b/assets/js/app.js
index 44a8122..d5e278a 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -23,12 +23,15 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
-let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
+let liveSocket = new LiveSocket("/live", Socket, {
+ longPollFallbackMs: 2500,
+ params: {_csrf_token: csrfToken}
+})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
-window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
-window.addEventListener("phx:page-loading-stop", info => topbar.hide())
+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
index b611701..974aaab 100644
--- a/assets/tailwind.config.js
+++ b/assets/tailwind.config.js
@@ -2,12 +2,14 @@
// https://tailwindcss.com/docs/configuration
const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
module.exports = {
content: [
"./js/**/*.js",
- "../lib/*_web.ex",
- "../lib/*_web/**/*.*ex"
+ "../lib/ppr_api_web.ex",
+ "../lib/ppr_api_web/**/*.*ex"
],
theme: {
extend: {
@@ -18,9 +20,55 @@ module.exports = {
},
plugins: [
require("@tailwindcss/forms"),
- plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
+ // Allows prefixing tailwind classes with LiveView classes to add rules
+ // only when LiveView classes are applied, for example:
+ //
+ //
+ //
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
- plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
+ plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
]
-}
\ No newline at end of file
+}
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
index 4176ede..4195727 100644
--- a/assets/vendor/topbar.js
+++ b/assets/vendor/topbar.js
@@ -1,9 +1,7 @@
/**
* @license MIT
- * topbar 1.0.0, 2021-01-06
- * Modifications:
- * - add delayedShow(time) (2022-09-21)
- * http://buunguyen.github.io/topbar
+ * topbar 2.0.0, 2023-02-04
+ * https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
@@ -98,26 +96,26 @@
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
- delayedShow: function(time) {
+ show: function (delay) {
if (showing) return;
- if (delayTimerId) return;
- delayTimerId = setTimeout(() => topbar.show(), time);
- },
- show: function () {
- if (showing) return;
- showing = true;
- if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
- if (!canvas) createCanvas();
- canvas.style.opacity = 1;
- canvas.style.display = "block";
- topbar.progress(0);
- if (options.autoRun) {
- (function loop() {
- progressTimerId = window.requestAnimationFrame(loop);
- topbar.progress(
- "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
- );
- })();
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
}
},
progress: function (to) {
diff --git a/build.sh b/build.sh
deleted file mode 100755
index b2e6795..0000000
--- a/build.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env bash
-# exit on error
-set -o errexit
-
-# Initial setup
-mix deps.get --only prod
-MIX_ENV=prod mix compile
-
-# Compile assets
-MIX_ENV=prod mix assets.deploy
-
-rm -rf "_build"
-
-# Create the release
-MIX_ENV=prod mix release
-
-# Run migrations
-_build/prod/rel/price_register/bin/price_register eval "PriceRegister.Release.migrate"
\ No newline at end of file
diff --git a/config/config.exs b/config/config.exs
index 5e44c01..3cc3173 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -7,18 +7,20 @@
# General application configuration
import Config
-config :price_register,
- ecto_repos: [PriceRegister.Repo]
+config :ppr_api,
+ ecto_repos: [PprApi.Repo],
+ generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
-config :price_register, PriceRegisterWeb.Endpoint,
+config :ppr_api, PprApiWeb.Endpoint,
url: [host: "localhost"],
+ adapter: Bandit.PhoenixAdapter,
render_errors: [
- formats: [html: PriceRegisterWeb.ErrorHTML, json: PriceRegisterWeb.ErrorJSON],
+ formats: [html: PprApiWeb.ErrorHTML, json: PprApiWeb.ErrorJSON],
layout: false
],
- pubsub_server: PriceRegister.PubSub,
- live_view: [signing_salt: "y1LwZ2BI"]
+ pubsub_server: PprApi.PubSub,
+ live_view: [signing_salt: "A5duZ7lj"]
# Configures the mailer
#
@@ -27,31 +29,41 @@ config :price_register, PriceRegisterWeb.Endpoint,
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
-config :price_register, PriceRegister.Mailer, adapter: Swoosh.Adapters.Local
+config :ppr_api, PprApi.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
- version: "0.14.41",
- default: [
+ version: "0.17.11",
+ ppr_api: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
-# Configure AppSignal
-
-config :appsignal, :config,
- otp_app: :price_register,
- name: "priceregister.civictech.ie",
- push_api_key: "b3b191f4-40c7-41f8-b540-48e5eb9ab642",
- env: Mix.env()
+# Configure tailwind (the version is required)
+config :tailwind,
+ version: "3.4.3",
+ ppr_api: [
+ args: ~w(
+ --config=tailwind.config.js
+ --input=css/app.css
+ --output=../priv/static/assets/app.css
+ ),
+ cd: Path.expand("../assets", __DIR__)
+ ]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
+config :ppr_api, PprApi.Scheduler,
+ jobs: [
+ {"* * * * *", {PprApi.Fetches, :fetch_latest_sales, []}},
+ {"0 1 * * 6", {PprApi.Fetches, :fetch_all_sales, [true]}}
+ ]
+
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
diff --git a/config/dev.exs b/config/dev.exs
index 883a895..9cfdf68 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -1,9 +1,9 @@
import Config
# Configure your database
-config :price_register, PriceRegister.Repo,
+config :ppr_api, PprApi.Repo,
hostname: "localhost",
- database: "price_register_dev",
+ database: "ppr_api_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
@@ -12,18 +12,19 @@ config :price_register, PriceRegister.Repo,
# debugging and code reloading.
#
# The watchers configuration can be used to run external
-# watchers to your application. For example, we use it
-# with esbuild to bundle .js and .css sources.
-config :price_register, PriceRegisterWeb.Endpoint,
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
+config :ppr_api, PprApiWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
- secret_key_base: "FcNR51iuQRCZ5zxUv+Qx6FPivmyScHfPsAeHIbZxhuUt7xzJTj1T/mwZ1T7xn4Mt",
+ secret_key_base: "UGJHgDI/2B71vShPnjLoIK46cfdczq8urfE23WW2H96G96ixMxpkOr5U2d6MdIr7",
watchers: [
- esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
+ esbuild: {Esbuild, :install_and_run, [:ppr_api, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:ppr_api, ~w(--watch)]}
]
# ## SSL Support
@@ -50,18 +51,17 @@ config :price_register, PriceRegisterWeb.Endpoint,
# different ports.
# Watch static and templates for browser reloading.
-config :price_register, PriceRegisterWeb.Endpoint,
+config :ppr_api, PprApiWeb.Endpoint,
live_reload: [
patterns: [
- ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
- ~r"lib/price_register_web/(live|views)/.*(ex)$",
- ~r"lib/price_register_web/templates/.*(eex)$"
+ ~r"lib/ppr_api_web/(controllers|live|components)/.*(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
-config :price_register, dev_routes: true
+config :ppr_api, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
@@ -73,7 +73,11 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
+config :phoenix_live_view,
+ # Include HEEx debug annotations as HTML comments in rendered markup
+ debug_heex_annotations: true,
+ # Enable helpful, but potentially expensive runtime checks
+ enable_expensive_runtime_checks: true
+
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
-
-config :appsignal, :config, active: true
diff --git a/config/prod.exs b/config/prod.exs
index 445dc24..4d0cb2b 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -1,27 +1,20 @@
import Config
-# For production, don't forget to configure the url host
-# to something meaningful, Phoenix uses this information
-# when generating URLs.
-
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
-# manifest is generated by the `mix phx.digest` task,
+# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
-config :price_register, PriceRegisterWeb.Endpoint,
- # url: [host: System.get_env("RENDER_EXTERNAL_HOSTNAME") || "localhost", port: 80],
- cache_static_manifest: "priv/static/cache_manifest.json"
-
-# check_origin: :conn
+config :ppr_api, PprApiWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Configures Swoosh API Client
-config :swoosh, :api_client, PriceRegister.Finch
+config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: PprApi.Finch
+
+# Disable Swoosh Local Memory Storage
+config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.
-
-config :appsignal, :config, active: true
diff --git a/config/runtime.exs b/config/runtime.exs
index 7d727c5..feb295d 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -12,12 +12,12 @@ import Config
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
-# PHX_SERVER=true bin/price_register start
+# PHX_SERVER=true bin/ppr_api start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
- config :price_register, PriceRegisterWeb.Endpoint, server: true
+ config :ppr_api, PprApiWeb.Endpoint, server: true
end
if config_env() == :prod do
@@ -28,9 +28,9 @@ if config_env() == :prod do
For example: ecto://USER:PASS@HOST/DATABASE
"""
- maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
- config :price_register, PriceRegister.Repo,
+ config :ppr_api, PprApi.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
@@ -51,12 +51,14 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
- config :price_register, PriceRegisterWeb.Endpoint,
+ config :ppr_api, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+ config :ppr_api, PprApiWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
- # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
@@ -68,7 +70,7 @@ if config_env() == :prod do
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
- # config :price_register, PriceRegisterWeb.Endpoint,
+ # config :ppr_api, PprApiWeb.Endpoint,
# https: [
# ...,
# port: 443,
@@ -87,10 +89,11 @@ if config_env() == :prod do
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
- # We also recommend setting `force_ssl` in your endpoint, ensuring
- # no data is ever sent via http, always redirecting to https:
+ # We also recommend setting `force_ssl` in your config/prod.exs,
+ # ensuring no data is ever sent via http, always redirecting to https:
#
- config :price_register, PriceRegisterWeb.Endpoint, force_ssl: [hsts: true]
+ # config :ppr_api, PprApiWeb.Endpoint,
+ # force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
@@ -100,7 +103,7 @@ if config_env() == :prod do
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
- # config :price_register, PriceRegister.Mailer,
+ # config :ppr_api, PprApi.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
diff --git a/config/test.exs b/config/test.exs
index d091e98..bc76126 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -5,25 +5,23 @@ import Config
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
-config :price_register, PriceRegister.Repo,
- username: "postgres",
- password: "postgres",
+config :ppr_api, PprApi.Repo,
hostname: "localhost",
- database: "price_register_test#{System.get_env("MIX_TEST_PARTITION")}",
+ database: "ppr_api_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
- pool_size: 10
+ pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
-config :price_register, PriceRegisterWeb.Endpoint,
+config :ppr_api, PprApiWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
- secret_key_base: "CmQCcPqohAOacKwwmBWYNi+CeKMx7qNigYYnPMXOsnV9T/JdZlhr+uY+dRTUHXmI",
+ secret_key_base: "Guqnh5f+0M/7G+3AtWiTPvOsIwfdDu0iPe2nb/VnSJGgtpeqa/yhgNsqsaQRu8hW",
server: false
-# In test we don't send emails.
-config :price_register, PriceRegister.Mailer, adapter: Swoosh.Adapters.Test
+# In test we don't send emails
+config :ppr_api, PprApi.Mailer, adapter: Swoosh.Adapters.Test
-# Disable swoosh api client as it is only required for production adapters.
+# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
@@ -31,3 +29,7 @@ config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+ enable_expensive_runtime_checks: true
diff --git a/lib/.DS_Store b/lib/.DS_Store
deleted file mode 100644
index 222a3bc..0000000
Binary files a/lib/.DS_Store and /dev/null differ
diff --git a/lib/price_register.ex b/lib/ppr_api.ex
similarity index 67%
rename from lib/price_register.ex
rename to lib/ppr_api.ex
index ea85bd4..38fe7c3 100644
--- a/lib/price_register.ex
+++ b/lib/ppr_api.ex
@@ -1,6 +1,6 @@
-defmodule PriceRegister do
+defmodule PprApi do
@moduledoc """
- PriceRegister keeps the contexts that define your domain
+ PprApi keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
diff --git a/lib/ppr_api/application.ex b/lib/ppr_api/application.ex
new file mode 100644
index 0000000..270a7f3
--- /dev/null
+++ b/lib/ppr_api/application.ex
@@ -0,0 +1,35 @@
+defmodule PprApi.Application do
+ @moduledoc false
+
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+ PprApiWeb.Telemetry,
+ PprApi.Repo,
+ {DNSCluster, query: Application.get_env(:ppr_api, :dns_cluster_query) || :ignore},
+ {Phoenix.PubSub, name: PprApi.PubSub},
+ {Finch, name: PprApi.Finch},
+ PprApiWeb.Endpoint
+ ]
+
+ children =
+ if Mix.env() != :test do
+ children ++ [PprApi.Scheduler]
+ else
+ children
+ end
+
+ # See https://hexdocs.pm/elixir/Supervisor.html
+ # for other strategies and supported options
+ opts = [strategy: :one_for_one, name: PprApi.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+
+ @impl true
+ def config_change(changed, _new, removed) do
+ PprApiWeb.Endpoint.config_change(changed, removed)
+ :ok
+ end
+end
diff --git a/lib/ppr_api/fetcher.ex b/lib/ppr_api/fetcher.ex
new file mode 100644
index 0000000..1107ea6
--- /dev/null
+++ b/lib/ppr_api/fetcher.ex
@@ -0,0 +1,164 @@
+defmodule PprApi.Fetcher do
+ alias PprApi.{ResidentialSales, Fetches}
+ alias PprApi.Fetches.Fetch
+ alias NimbleCSV.RFC4180, as: CSV
+
+ @base_url "https://propertypriceregister.ie/website/npsra/ppr/npsra-ppr.nsf/Downloads/"
+ @wait_time 2_000
+
+ @doc """
+ Fetches from PPR, month by month, from fetch.starts_on up to the current month.
+ Only works if fetch is in the "starting" state.
+ Updates `Fetch` record as it progresses, marking success or error on completion.
+ """
+ def run_fetch(%Fetch{status: "starting"} = fetch) do
+ try do
+ fetch
+ |> Fetches.mark_fetch_as_fetching()
+ |> fetch_months_recursively(fetch.starts_on)
+ # Mark success at the end
+ |> Fetches.mark_fetch_success()
+ rescue
+ e ->
+ # On error, record the message
+ Fetches.mark_fetch_error(fetch, Exception.message(e))
+ end
+ end
+
+ def run_fetch(%Fetch{status: status} = _fetch) do
+ {:error, "Cannot run fetch because its current state is '#{status}'."}
+ end
+
+ # We recursively fetch from `current_date` until we reach today's month
+ defp fetch_months_recursively(%Fetch{} = fetch, %Date{} = current_date) do
+ if Mix.env() != :test do
+ Process.sleep(@wait_time)
+ end
+
+ current_date
+ |> fetch_data_for_month()
+ |> parse_csv()
+ |> upsert_rows()
+ |> update_fetch_progress(fetch, current_date)
+
+ next_month =
+ current_date
+ |> Date.end_of_month()
+ |> Date.add(1)
+ |> Date.beginning_of_month()
+
+ # Keep going if we're still behind the current month
+ if Date.compare(next_month, Date.utc_today() |> Date.beginning_of_month()) == :lt do
+ fetch_months_recursively(fetch, next_month)
+ end
+
+ fetch
+ end
+
+ # Builds the URL and fetches the CSV data for a given Date
+ defp fetch_data_for_month(%Date{} = date) do
+ %{body: body} =
+ date
+ |> url_for_month()
+ |> HTTPoison.get!(%{}, hackney: [:insecure])
+
+ body
+ end
+
+ defp url_for_month(%Date{year: year, month: month}) do
+ year_str = Integer.to_string(year)
+ month_str = month |> Integer.to_string() |> String.pad_leading(2, "0")
+ csv_file = "PPR-#{year_str}-#{month_str}.csv"
+
+ "#{@base_url}#{csv_file}/$FILE/#{csv_file}"
+ end
+
+ # Parses CSV data into a list of maps that match the ResidentialSale fields
+ defp parse_csv(csv_data) do
+ csv_data
+ |> CSV.parse_string(skip_headers: true)
+ |> Enum.map(&parse_row/1)
+ end
+
+ # Convert a single CSV row into a map (with the same keys as ResidentialSale)
+ defp parse_row(row_data) do
+ keys = [
+ :date_of_sale,
+ :address,
+ :county,
+ :eircode,
+ :price_in_euros,
+ :not_full_market_price,
+ :vat_exclusive,
+ :description_of_property,
+ :property_size_description
+ ]
+
+ row_data
+ |> decode_cp1252()
+ |> Enum.zip(keys)
+ |> Enum.into(%{}, fn {raw_value, key} ->
+ {key, parse_column(key, raw_value)}
+ end)
+ end
+
+ defp decode_cp1252(row) do
+ Enum.map(row, &Mbcs.decode!(&1, :cp1252))
+ end
+
+ # Pattern-match on certain fields, else treat as text
+ defp parse_column(:date_of_sale, value), do: value |> normalise_text() |> parse_date()
+ defp parse_column(:price_in_euros, value), do: value |> normalise_text() |> parse_price()
+
+ defp parse_column(:not_full_market_price, value),
+ do: value |> normalise_text() |> parse_boolean()
+
+ defp parse_column(:vat_exclusive, value), do: value |> normalise_text() |> parse_boolean()
+ defp parse_column(_other_key, value), do: parse_text(value)
+
+ defp parse_text(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ end
+
+ # Date strings come in as DD/MM/YYYY
+ defp parse_date(date_str) do
+ [day, month, year] =
+ date_str
+ |> String.split("/")
+ |> Enum.map(&String.to_integer/1)
+
+ Date.new!(year, month, day)
+ end
+
+ # Price strings might have "€" or commas
+ defp parse_price("€" <> rest), do: parse_price(rest)
+
+ defp parse_price(str) do
+ str
+ |> String.replace(",", "")
+ |> Decimal.new()
+ end
+
+ # Convert "yes"/"no" to booleans
+ defp parse_boolean("yes"), do: true
+ defp parse_boolean("no"), do: false
+ defp parse_boolean(other), do: parse_boolean(normalise_text(other))
+
+ defp normalise_text(str), do: str |> String.downcase() |> String.trim()
+
+ defp upsert_rows(rows) do
+ # Upsert rows into the database, returning the count of upserted rows
+ ResidentialSales.upsert_rows(rows)
+ end
+
+ defp update_fetch_progress(count, fetch, current_month) do
+ fetch
+ |> Fetches.update_fetch_progress(%{
+ status: "fetching",
+ current_month: current_month,
+ increment_by: count
+ })
+ end
+end
diff --git a/lib/ppr_api/fetches.ex b/lib/ppr_api/fetches.ex
new file mode 100644
index 0000000..00088a5
--- /dev/null
+++ b/lib/ppr_api/fetches.ex
@@ -0,0 +1,154 @@
+defmodule PprApi.Fetches do
+ @moduledoc """
+ The Fetches context.
+ """
+
+ import Ecto.Query
+ alias PprApi.ResidentialSales
+ alias PprApi.Repo
+ alias PprApi.PropertyRegister
+ alias PprApi.Fetches.Fetch
+ alias PprApiWeb.Endpoint
+
+ @doc """
+ List all fetch records, most recent first.
+ """
+ def list_fetches do
+ Repo.all(from f in Fetch, order_by: [desc: f.inserted_at])
+ end
+
+ @doc """
+ Fetch the latest updates from the Property Register.
+ Starts syncing from the month of the latest sale in the db.
+ Uses update_due? to decide if it should fetch, unless force is true.
+ """
+ def fetch_latest_sales(force \\ false) do
+ if force || update_due?() do
+ fetch_since(ResidentialSales.latest_sale_date())
+ else
+ {:ok, :no_update_needed}
+ end
+ end
+
+ @doc """
+ Fetch everything from Property Register since 2010.
+ """
+ def fetch_all_sales(force \\ false) do
+ if force || update_due?() do
+ fetch_since(nil)
+ else
+ {:ok, :no_update_needed}
+ end
+ end
+
+ defp update_due? do
+ time_of_latest_fetch()
+ |> PropertyRegister.has_the_register_been_updated_since?()
+ end
+
+ defp time_of_latest_fetch do
+ query =
+ from(f in Fetch,
+ where: f.status in ["starting", "success", "fetching"],
+ order_by: [desc: f.started_at],
+ limit: 1
+ )
+
+ case Repo.one(query) do
+ nil ->
+ nil
+
+ fetch ->
+ fetch.started_at
+ end
+ end
+
+ defp fetch_since(starts_on) do
+ fetch =
+ %Fetch{}
+ |> Fetch.changeset(%{
+ starts_on: starts_on || ~D[2010-01-01],
+ current_month: starts_on,
+ started_at: DateTime.utc_now()
+ })
+ |> Repo.insert!()
+
+ broadcast_updates()
+
+ Task.start(fn ->
+ PprApi.Fetcher.run_fetch(fetch)
+ end)
+
+ {:ok, fetch}
+ end
+
+ @doc """
+ Mark the fetch as fetching.
+ Can only be called once on a fetch of status 'starting'.
+ """
+ def mark_fetch_as_fetching(%Fetch{status: "starting"} = fetch) do
+ updated_fetch =
+ fetch
+ |> Fetch.changeset(%{status: "fetching"})
+ |> Repo.update!()
+
+ broadcast_updates()
+ updated_fetch
+ end
+
+ @doc """
+ Update a fetch record's progress, typically called from within the running task.
+ Only should be called when the fetch is 'fetching'.
+ """
+ def update_fetch_progress(%Fetch{status: "fetching"} = fetch, attrs) do
+ updated_fetch =
+ from(f in Fetch,
+ where: f.id == ^fetch.id,
+ update: [
+ set: [current_month: ^attrs[:current_month]],
+ inc: [total_rows: ^attrs[:increment_by]]
+ ]
+ )
+ |> Repo.update_all([])
+
+ broadcast_updates()
+ updated_fetch
+ end
+
+ @doc """
+ Mark a fetch as successfully completed, setting status and finished time.
+ """
+ def mark_fetch_success(%Fetch{status: "fetching"} = fetch) do
+ updated_fetch =
+ fetch
+ |> Fetch.changeset(%{
+ status: "success",
+ finished_at: DateTime.utc_now()
+ })
+ |> Repo.update!()
+
+ broadcast_updates()
+ updated_fetch
+ end
+
+ @doc """
+ Mark a fetch as failed, capturing any error message and finishing time.
+ """
+ def mark_fetch_error(%Fetch{} = fetch, error_message) do
+ updated_fetch =
+ fetch
+ |> Fetch.changeset(%{
+ status: "error",
+ error_message: error_message,
+ finished_at: DateTime.utc_now()
+ })
+ |> Repo.update!()
+
+ broadcast_updates()
+ updated_fetch
+ end
+
+ defp broadcast_updates do
+ Endpoint.broadcast("fetches_topic", "fetches_updated", %{})
+ end
+end
diff --git a/lib/ppr_api/fetches/fetch.ex b/lib/ppr_api/fetches/fetch.ex
new file mode 100644
index 0000000..1ee6179
--- /dev/null
+++ b/lib/ppr_api/fetches/fetch.ex
@@ -0,0 +1,32 @@
+defmodule PprApi.Fetches.Fetch do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "fetches" do
+ field :status, :string, default: "starting"
+ field :starts_on, :date
+ field :current_month, :date
+ field :total_rows, :integer, default: 0
+ field :error_message, :string
+ field :started_at, :utc_datetime
+ field :finished_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(fetch, attrs) do
+ fetch
+ |> cast(attrs, [
+ :status,
+ :starts_on,
+ :current_month,
+ :total_rows,
+ :error_message,
+ :started_at,
+ :finished_at
+ ])
+ |> validate_required([:starts_on])
+ |> validate_inclusion(:status, ["starting", "fetching", "success", "error"])
+ end
+end
diff --git a/lib/ppr_api/fingerprint_helper.ex b/lib/ppr_api/fingerprint_helper.ex
new file mode 100644
index 0000000..ba142c1
--- /dev/null
+++ b/lib/ppr_api/fingerprint_helper.ex
@@ -0,0 +1,30 @@
+defmodule PprApi.FingerprintHelper do
+ @moduledoc """
+ Provides a shared function to compute the fingerprint for a residential sale row.
+ """
+
+ @doc """
+ Computes the same fingerprint used in ResidentialSale.put_fingerprint/1,
+ but for a plain map (row).
+ """
+ def compute_fingerprint(row) do
+ data_string =
+ [
+ row[:date_of_sale] |> to_string(),
+ row[:address],
+ row[:county],
+ row[:eircode],
+ row[:price_in_euros] |> to_string(),
+ row[:not_full_market_price] |> to_string(),
+ row[:vat_exclusive] |> to_string(),
+ row[:description_of_property],
+ row[:property_size_description]
+ ]
+ # replace nil with ""
+ |> Enum.map(&(&1 || ""))
+ |> Enum.join("|")
+
+ :crypto.hash(:sha256, data_string)
+ |> Base.encode16(case: :lower)
+ end
+end
diff --git a/lib/ppr_api/mailer.ex b/lib/ppr_api/mailer.ex
new file mode 100644
index 0000000..7c8543e
--- /dev/null
+++ b/lib/ppr_api/mailer.ex
@@ -0,0 +1,3 @@
+defmodule PprApi.Mailer do
+ use Swoosh.Mailer, otp_app: :ppr_api
+end
diff --git a/lib/ppr_api/pagination.ex b/lib/ppr_api/pagination.ex
new file mode 100644
index 0000000..c823b96
--- /dev/null
+++ b/lib/ppr_api/pagination.ex
@@ -0,0 +1,46 @@
+defmodule PprApi.Pagination do
+ import Ecto.Query
+
+ @default_per_page 250
+ @max_per_page 1000
+
+ def parse_pagination_params(params) do
+ page =
+ case Map.get(params, "page", "1") do
+ value when is_binary(value) -> String.to_integer(value)
+ value when is_integer(value) -> value
+ end
+
+ per_page =
+ case Map.get(params, "per_page", @default_per_page) do
+ value when is_binary(value) -> String.to_integer(value)
+ value when is_integer(value) -> value
+ end
+ |> min(@max_per_page)
+
+ [page: page, per_page: per_page]
+ end
+
+ def paginate(query, repo, opts \\ []) do
+ page = Keyword.get(opts, :page, 1)
+ per_page = Keyword.get(opts, :per_page, @default_per_page) |> min(@max_per_page)
+
+ total_count = repo.aggregate(query, :count)
+
+ entries =
+ query
+ |> limit(^per_page)
+ |> offset(^((page - 1) * per_page))
+ |> repo.all()
+
+ %{
+ entries: entries,
+ metadata: %{
+ page: page,
+ per_page: per_page,
+ total_count: total_count,
+ total_pages: ceil(total_count / per_page)
+ }
+ }
+ end
+end
diff --git a/lib/ppr_api/property_register.ex b/lib/ppr_api/property_register.ex
new file mode 100644
index 0000000..80a390d
--- /dev/null
+++ b/lib/ppr_api/property_register.ex
@@ -0,0 +1,44 @@
+defmodule PprApi.PropertyRegister do
+ alias Floki
+ alias Timex
+
+ @url "https://propertypriceregister.ie"
+
+ def has_the_register_been_updated_since?(nil), do: true
+
+ def has_the_register_been_updated_since?(time) do
+ case fetch_last_update_time() do
+ {:ok, last_update_time} ->
+ DateTime.compare(DateTime.from_naive!(last_update_time, "Etc/UTC"), time) == :gt
+
+ {:error, _} ->
+ false
+ end
+ end
+
+ defp fetch_last_update_time do
+ case HTTPoison.get(@url) do
+ {:ok, %{body: body}} ->
+ parse_last_update_time(body)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp parse_last_update_time(html) do
+ {:ok, document} = Floki.parse_document(html)
+
+ case Floki.find(document, "div.well h4") do
+ [update_time | _] ->
+ update_time
+ |> Floki.text()
+ |> String.replace("REGISTER LAST UPDATED - ", "")
+ |> Timex.parse!("{0D}/{0M}/{YYYY} {h24}:{m}:{s}")
+ |> then(&{:ok, &1})
+
+ _ ->
+ {:error, :timestamp_not_found}
+ end
+ end
+end
diff --git a/lib/ppr_api/repo.ex b/lib/ppr_api/repo.ex
new file mode 100644
index 0000000..5fc3a7b
--- /dev/null
+++ b/lib/ppr_api/repo.ex
@@ -0,0 +1,5 @@
+defmodule PprApi.Repo do
+ use Ecto.Repo,
+ otp_app: :ppr_api,
+ adapter: Ecto.Adapters.Postgres
+end
diff --git a/lib/ppr_api/residential_sales.ex b/lib/ppr_api/residential_sales.ex
new file mode 100644
index 0000000..e1976e8
--- /dev/null
+++ b/lib/ppr_api/residential_sales.ex
@@ -0,0 +1,88 @@
+defmodule PprApi.ResidentialSales do
+ import Ecto.Query, warn: false
+ alias PprApi.Repo
+ alias PprApi.ResidentialSales.ResidentialSale
+ alias PprApi.FingerprintHelper
+ alias PprApi.Pagination
+
+ @doc """
+ Returns the list of residential_sales ordered by date of sale.
+ """
+ def list_residential_sales(opts \\ []) do
+ ResidentialSale
+ |> order_by([rs], desc: rs.date_of_sale, desc: rs.inserted_at, desc: rs.id)
+ |> Pagination.paginate(Repo, opts)
+ end
+
+ @doc """
+ Returns the date of the most recent residential sale.
+ """
+ def latest_sale_date do
+ ResidentialSale
+ |> select([s], max(s.date_of_sale))
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets a single residential_sale.
+
+ Raises `Ecto.NoResultsError` if the Residential sale does not exist.
+ """
+ def get_residential_sale!(id), do: Repo.get!(ResidentialSale, id)
+
+ @doc """
+ Inserts residential sales in batches of 1000.
+ """
+
+ def upsert_rows(rows) do
+ # Define the batch size
+ batch_size = 1000
+
+ # Split rows into chunks of batch_size
+ rows
+ |> Enum.chunk_every(batch_size)
+ |> Enum.reduce(0, fn chunk, acc ->
+ # Process each chunk and get the count of inserted rows
+ inserted = upsert_batch(chunk)
+
+ # Add to the total count
+ acc + inserted
+ end)
+ end
+
+ defp upsert_batch(rows) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ # Add fingerprints and timestamps
+ rows_with_fingerprints =
+ Enum.map(rows, fn row ->
+ row
+ |> Map.put(:fingerprint, FingerprintHelper.compute_fingerprint(row))
+ |> Map.put(:inserted_at, now)
+ |> Map.put(:updated_at, now)
+ end)
+
+ # Remove duplicate fingerprints within the batch
+ unique_rows = remove_duplicate_fingerprints(rows_with_fingerprints)
+
+ # Perform the bulk upsert, counting only inserted rows
+ {rows_inserted, _} =
+ Repo.insert_all(
+ ResidentialSale,
+ unique_rows,
+ on_conflict: [set: [updated_at: now]],
+ conflict_target: :fingerprint
+ )
+
+ rows_inserted
+ end
+
+ # Helper to remove duplicate fingerprints, keeping the first occurrence
+ defp remove_duplicate_fingerprints(rows_with_fingerprints) do
+ rows_with_fingerprints
+ |> Enum.reduce(%{}, fn row, acc ->
+ Map.put_new(acc, row.fingerprint, row)
+ end)
+ |> Map.values()
+ end
+end
diff --git a/lib/ppr_api/residential_sales/residential_sale.ex b/lib/ppr_api/residential_sales/residential_sale.ex
new file mode 100644
index 0000000..f928988
--- /dev/null
+++ b/lib/ppr_api/residential_sales/residential_sale.ex
@@ -0,0 +1,61 @@
+defmodule PprApi.ResidentialSales.ResidentialSale do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias PprApi.FingerprintHelper
+
+ schema "residential_sales" do
+ field :address, :string
+ field :date_of_sale, :date
+ field :county, :string
+ field :eircode, :string
+ field :price_in_euros, :decimal
+ field :not_full_market_price, :boolean, default: false
+ field :vat_exclusive, :boolean, default: false
+ field :description_of_property, :string
+ field :property_size_description, :string
+ field :fingerprint, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(residential_sale, attrs) do
+ residential_sale
+ |> cast(attrs, [
+ :date_of_sale,
+ :address,
+ :county,
+ :eircode,
+ :price_in_euros,
+ :not_full_market_price,
+ :vat_exclusive,
+ :description_of_property,
+ :property_size_description
+ ])
+ |> put_fingerprint()
+ |> validate_required([
+ :date_of_sale,
+ :address,
+ :fingerprint
+ ])
+ end
+
+ defp put_fingerprint(changeset) do
+ row = %{
+ date_of_sale: get_field(changeset, :date_of_sale),
+ address: get_field(changeset, :address),
+ county: get_field(changeset, :county),
+ eircode: get_field(changeset, :eircode),
+ price_in_euros: get_field(changeset, :price_in_euros),
+ not_full_market_price: get_field(changeset, :not_full_market_price),
+ vat_exclusive: get_field(changeset, :vat_exclusive),
+ description_of_property: get_field(changeset, :description_of_property),
+ property_size_description: get_field(changeset, :property_size_description)
+ }
+
+ fingerprint = FingerprintHelper.compute_fingerprint(row)
+
+ put_change(changeset, :fingerprint, fingerprint)
+ end
+end
diff --git a/lib/ppr_api/scheduler.ex b/lib/ppr_api/scheduler.ex
new file mode 100644
index 0000000..3cc0bb6
--- /dev/null
+++ b/lib/ppr_api/scheduler.ex
@@ -0,0 +1,3 @@
+defmodule PprApi.Scheduler do
+ use Quantum, otp_app: :ppr_api
+end
diff --git a/lib/price_register_web.ex b/lib/ppr_api_web.ex
similarity index 77%
rename from lib/price_register_web.ex
rename to lib/ppr_api_web.ex
index 52f671c..daea45d 100644
--- a/lib/price_register_web.ex
+++ b/lib/ppr_api_web.ex
@@ -1,12 +1,12 @@
-defmodule PriceRegisterWeb do
+defmodule PprApiWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
- use PriceRegisterWeb, :controller
- use PriceRegisterWeb, :html
+ use PprApiWeb, :controller
+ use PprApiWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
@@ -39,12 +39,12 @@ defmodule PriceRegisterWeb do
def controller do
quote do
use Phoenix.Controller,
- namespace: PriceRegisterWeb,
formats: [:html, :json],
- layouts: [html: PriceRegisterWeb.Layouts]
+ layouts: [html: PprApiWeb.Layouts]
+
+ use Gettext, backend: PprApiWeb.Gettext
import Plug.Conn
- import PriceRegisterWeb.Gettext
unquote(verified_routes())
end
@@ -53,7 +53,7 @@ defmodule PriceRegisterWeb do
def live_view do
quote do
use Phoenix.LiveView,
- layout: {PriceRegisterWeb.Layouts, :app}
+ layout: {PprApiWeb.Layouts, :app}
unquote(html_helpers())
end
@@ -82,11 +82,13 @@ defmodule PriceRegisterWeb do
defp html_helpers do
quote do
+ # Translation
+ use Gettext, backend: PprApiWeb.Gettext
+
# HTML escaping functionality
import Phoenix.HTML
- # Core UI components and translation
- import PriceRegisterWeb.CoreComponents
- import PriceRegisterWeb.Gettext
+ # Core UI components
+ import PprApiWeb.CoreComponents
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
@@ -99,14 +101,14 @@ defmodule PriceRegisterWeb do
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
- endpoint: PriceRegisterWeb.Endpoint,
- router: PriceRegisterWeb.Router,
- statics: PriceRegisterWeb.static_paths()
+ endpoint: PprApiWeb.Endpoint,
+ router: PprApiWeb.Router,
+ statics: PprApiWeb.static_paths()
end
end
@doc """
- When used, dispatch to the appropriate controller/view/etc.
+ When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
diff --git a/lib/ppr_api_web/components/core_components.ex b/lib/ppr_api_web/components/core_components.ex
new file mode 100644
index 0000000..0c136ed
--- /dev/null
+++ b/lib/ppr_api_web/components/core_components.ex
@@ -0,0 +1,168 @@
+defmodule PprApiWeb.CoreComponents do
+ @moduledoc """
+ Provides some basic components.
+ """
+
+ use Phoenix.Component
+ use Gettext, backend: PprApiWeb.Gettext
+
+ alias Phoenix.LiveView.JS
+
+ @doc """
+ Renders a button.
+
+ @doc \"""
+ Renders a header with title.
+ """
+ attr :class, :string, default: nil
+
+ slot :inner_block, required: true
+ slot :subtitle
+ slot :actions
+
+ def header(assigns) do
+ ~H"""
+
+
+
+ {render_slot(@inner_block)}
+
+
+ {render_slot(@subtitle)}
+
+
+
{render_slot(@actions)}
+
+ """
+ end
+
+ @doc ~S"""
+ Renders a table with generic styling.
+
+ ## Examples
+
+ <.table id="users" rows={@users}>
+ <:col :let={user} label="id">{user.id}
+ <:col :let={user} label="username">{user.username}
+
+ """
+ attr :id, :string, required: true
+ attr :rows, :list, required: true
+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
+
+ attr :row_item, :any,
+ default: &Function.identity/1,
+ doc: "the function for mapping each row before calling the :col and :action slots"
+
+ slot :col, required: true do
+ attr :label, :string
+ end
+
+ slot :action, doc: "the slot for showing user actions in the last table column"
+
+ def table(assigns) do
+ assigns =
+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
+ end
+
+ ~H"""
+
+
+
+
+
{col[:label]}
+
+ {gettext("Actions")}
+
+
+
+
+
+
+
+
+
+ {render_slot(col, @row_item.(row))}
+
+
+
+
+
+
+
+ {render_slot(action, @row_item.(row))}
+
+
+
+
+
+
+
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ time: 300,
+ transition:
+ {"transition-all transform ease-out duration-300",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+ "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition:
+ {"transition-all transform ease-in duration-200",
+ "opacity-100 translate-y-0 sm:scale-100",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # When using gettext, we typically pass the strings we want
+ # to translate as a static argument:
+ #
+ # # Translate the number of files with plural rules
+ # dngettext("errors", "1 file", "%{count} files", count)
+ #
+ # However the error messages in our forms and APIs are generated
+ # dynamically, so we need to translate them by calling Gettext
+ # with our gettext backend as first argument. Translations are
+ # available in the errors.po file (as we use the "errors" domain).
+ if count = opts[:count] do
+ Gettext.dngettext(PprApiWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(PprApiWeb.Gettext, "errors", msg, opts)
+ end
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+end
diff --git a/lib/ppr_api_web/components/layouts.ex b/lib/ppr_api_web/components/layouts.ex
new file mode 100644
index 0000000..e851984
--- /dev/null
+++ b/lib/ppr_api_web/components/layouts.ex
@@ -0,0 +1,14 @@
+defmodule PprApiWeb.Layouts do
+ @moduledoc """
+ This module holds different layouts used by your application.
+
+ See the `layouts` directory for all templates available.
+ The "root" layout is a skeleton rendered as part of the
+ application router. The "app" layout is set as the default
+ layout on both `use PprApiWeb, :controller` and
+ `use PprApiWeb, :live_view`.
+ """
+ use PprApiWeb, :html
+
+ embed_templates "layouts/*"
+end
diff --git a/lib/ppr_api_web/components/layouts/app.html.heex b/lib/ppr_api_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..6e75419
--- /dev/null
+++ b/lib/ppr_api_web/components/layouts/app.html.heex
@@ -0,0 +1,8 @@
+
+ Property Price Register API
+ Status
+
+
+
+ {@inner_content}
+
diff --git a/lib/ppr_api_web/components/layouts/root.html.heex b/lib/ppr_api_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..16b47e3
--- /dev/null
+++ b/lib/ppr_api_web/components/layouts/root.html.heex
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ <.live_title default="Home" suffix=" · Property Price Register API">
+ {assigns[:page_title]}
+
+
+
+
+
+ {@inner_content}
+
+
diff --git a/lib/ppr_api_web/controllers/api/residential_sale_controller.ex b/lib/ppr_api_web/controllers/api/residential_sale_controller.ex
new file mode 100644
index 0000000..781ce4c
--- /dev/null
+++ b/lib/ppr_api_web/controllers/api/residential_sale_controller.ex
@@ -0,0 +1,12 @@
+defmodule PprApiWeb.API.ResidentialSaleController do
+ use PprApiWeb, :controller
+
+ alias PprApi.ResidentialSales
+
+ action_fallback PprApiWeb.FallbackController
+
+ def index(conn, _params) do
+ residential_sales = ResidentialSales.list_residential_sales(limit: 100)
+ render(conn, :index, residential_sales: residential_sales)
+ end
+end
diff --git a/lib/ppr_api_web/controllers/api/residential_sale_json.ex b/lib/ppr_api_web/controllers/api/residential_sale_json.ex
new file mode 100644
index 0000000..44f205d
--- /dev/null
+++ b/lib/ppr_api_web/controllers/api/residential_sale_json.ex
@@ -0,0 +1,32 @@
+defmodule PprApiWeb.API.ResidentialSaleJSON do
+ alias PprApi.ResidentialSales.ResidentialSale
+
+ @doc """
+ Renders a list of residential_sales.
+ """
+ def index(%{residential_sales: residential_sales}) do
+ %{data: for(residential_sale <- residential_sales, do: data(residential_sale))}
+ end
+
+ @doc """
+ Renders a single residential_sale.
+ """
+ def show(%{residential_sale: residential_sale}) do
+ %{data: data(residential_sale)}
+ end
+
+ defp data(%ResidentialSale{} = residential_sale) do
+ %{
+ id: residential_sale.id,
+ date_of_sale: residential_sale.date_of_sale,
+ address: residential_sale.address,
+ county: residential_sale.county,
+ eircode: residential_sale.eircode,
+ price_in_euros: residential_sale.price_in_euros,
+ not_full_market_price: residential_sale.not_full_market_price,
+ vat_exclusive: residential_sale.vat_exclusive,
+ description_of_property: residential_sale.description_of_property,
+ property_size_description: residential_sale.property_size_description
+ }
+ end
+end
diff --git a/lib/price_register_web/controllers/changeset_json.ex b/lib/ppr_api_web/controllers/changeset_json.ex
similarity index 75%
rename from lib/price_register_web/controllers/changeset_json.ex
rename to lib/ppr_api_web/controllers/changeset_json.ex
index d0ccf33..136fdb1 100644
--- a/lib/price_register_web/controllers/changeset_json.ex
+++ b/lib/ppr_api_web/controllers/changeset_json.ex
@@ -1,4 +1,4 @@
-defmodule PriceRegisterWeb.ChangesetJSON do
+defmodule PprApiWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
@@ -13,9 +13,9 @@ defmodule PriceRegisterWeb.ChangesetJSON do
# uncommenting and adjusting the following code:
# if count = opts[:count] do
- # Gettext.dngettext(PriceRegisterWeb.Gettext, "errors", msg, msg, count, opts)
+ # Gettext.dngettext(PprApiWeb.Gettext, "errors", msg, msg, count, opts)
# else
- # Gettext.dgettext(PriceRegisterWeb.Gettext, "errors", msg, opts)
+ # Gettext.dgettext(PprApiWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
diff --git a/lib/price_register_web/controllers/error_html.ex b/lib/ppr_api_web/controllers/error_html.ex
similarity index 52%
rename from lib/price_register_web/controllers/error_html.ex
rename to lib/ppr_api_web/controllers/error_html.ex
index 1b2e878..95471b1 100644
--- a/lib/price_register_web/controllers/error_html.ex
+++ b/lib/ppr_api_web/controllers/error_html.ex
@@ -1,14 +1,19 @@
-defmodule PriceRegisterWeb.ErrorHTML do
- use PriceRegisterWeb, :html
+defmodule PprApiWeb.ErrorHTML do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on HTML requests.
+
+ See config/config.exs.
+ """
+ use PprApiWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
- # * lib/price_register_web/controllers/error/404.html.heex
- # * lib/price_register_web/controllers/error/500.html.heex
+ # * lib/ppr_api_web/controllers/error_html/404.html.heex
+ # * lib/ppr_api_web/controllers/error_html/500.html.heex
#
- # embed_templates "error/*"
+ # embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
diff --git a/lib/price_register_web/controllers/error_json.ex b/lib/ppr_api_web/controllers/error_json.ex
similarity index 74%
rename from lib/price_register_web/controllers/error_json.ex
rename to lib/ppr_api_web/controllers/error_json.ex
index 02f67f1..f23ea41 100644
--- a/lib/price_register_web/controllers/error_json.ex
+++ b/lib/ppr_api_web/controllers/error_json.ex
@@ -1,4 +1,10 @@
-defmodule PriceRegisterWeb.ErrorJSON do
+defmodule PprApiWeb.ErrorJSON do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on JSON requests.
+
+ See config/config.exs.
+ """
+
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
diff --git a/lib/price_register_web/controllers/fallback_controller.ex b/lib/ppr_api_web/controllers/fallback_controller.ex
similarity index 72%
rename from lib/price_register_web/controllers/fallback_controller.ex
rename to lib/ppr_api_web/controllers/fallback_controller.ex
index e8de8fa..e1e8954 100644
--- a/lib/price_register_web/controllers/fallback_controller.ex
+++ b/lib/ppr_api_web/controllers/fallback_controller.ex
@@ -1,16 +1,16 @@
-defmodule PriceRegisterWeb.FallbackController do
+defmodule PprApiWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
- use PriceRegisterWeb, :controller
+ use PprApiWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
- |> put_view(json: PriceRegisterWeb.ChangesetJSON)
+ |> put_view(json: PprApiWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
@@ -18,7 +18,7 @@ defmodule PriceRegisterWeb.FallbackController do
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
- |> put_view(html: PriceRegisterWeb.ErrorHTML, json: PriceRegisterWeb.ErrorJSON)
+ |> put_view(html: PprApiWeb.ErrorHTML, json: PprApiWeb.ErrorJSON)
|> render(:"404")
end
end
diff --git a/lib/ppr_api_web/controllers/fetch_controller.ex b/lib/ppr_api_web/controllers/fetch_controller.ex
new file mode 100644
index 0000000..972b261
--- /dev/null
+++ b/lib/ppr_api_web/controllers/fetch_controller.ex
@@ -0,0 +1,11 @@
+defmodule PprApiWeb.FetchController do
+ use PprApiWeb, :controller
+
+ alias PprApi.Fetches
+
+ # Render the main fetch page with buttons
+ def index(conn, _params) do
+ fetches = Fetches.list_fetches()
+ render(conn, :index, fetches: fetches)
+ end
+end
diff --git a/lib/ppr_api_web/controllers/fetch_html.ex b/lib/ppr_api_web/controllers/fetch_html.ex
new file mode 100644
index 0000000..7d3cda3
--- /dev/null
+++ b/lib/ppr_api_web/controllers/fetch_html.ex
@@ -0,0 +1,5 @@
+defmodule PprApiWeb.FetchHTML do
+ use PprApiWeb, :html
+
+ embed_templates "fetch_html/*"
+end
diff --git a/lib/ppr_api_web/controllers/residential_sale_controller.ex b/lib/ppr_api_web/controllers/residential_sale_controller.ex
new file mode 100644
index 0000000..5b3d9ce
--- /dev/null
+++ b/lib/ppr_api_web/controllers/residential_sale_controller.ex
@@ -0,0 +1,46 @@
+defmodule PprApiWeb.ResidentialSaleController do
+ use PprApiWeb, :controller
+ alias PprApi.ResidentialSales
+
+ def index(conn, params) do
+ opts = parse_pagination_params(params)
+
+ %{entries: residential_sales, metadata: metadata} =
+ ResidentialSales.list_residential_sales(opts)
+
+ api_path = generate_api_path(conn, params)
+
+ render(conn, "index.html",
+ residential_sales: residential_sales,
+ metadata: metadata,
+ api_path: api_path
+ )
+ end
+
+ defp parse_pagination_params(params) do
+ limit =
+ case Map.get(params, "limit", 250) do
+ value when is_binary(value) -> String.to_integer(value)
+ value when is_integer(value) -> value
+ end
+
+ include_total_count =
+ case Map.get(params, "include_total_count", false) do
+ value when is_binary(value) -> String.to_existing_atom(value)
+ value when is_boolean(value) -> value
+ end
+
+ [
+ limit: limit,
+ after: Map.get(params, "after"),
+ before: Map.get(params, "before"),
+ include_total_count: include_total_count
+ ]
+ end
+
+ defp generate_api_path(conn, params) do
+ query_params = URI.encode_query(params)
+ api_path = ~p"/api/v1/residential/sales"
+ "#{api_path}?#{query_params}"
+ end
+end
diff --git a/lib/ppr_api_web/controllers/residential_sale_html.ex b/lib/ppr_api_web/controllers/residential_sale_html.ex
new file mode 100644
index 0000000..bba5419
--- /dev/null
+++ b/lib/ppr_api_web/controllers/residential_sale_html.ex
@@ -0,0 +1,5 @@
+defmodule PprApiWeb.ResidentialSaleHTML do
+ use PprApiWeb, :html
+
+ embed_templates "residential_sale_html/*"
+end
diff --git a/lib/ppr_api_web/controllers/residential_sale_html/index.html.heex b/lib/ppr_api_web/controllers/residential_sale_html/index.html.heex
new file mode 100644
index 0000000..a679b78
--- /dev/null
+++ b/lib/ppr_api_web/controllers/residential_sale_html/index.html.heex
@@ -0,0 +1,25 @@
+
+
+
+
+
+
Date of Sale
+
Address
+
County
+
Eircode
+
Price
+
+
+
+ <%= for residential_sale <- @residential_sales do %>
+
+
{residential_sale.date_of_sale}
+
{residential_sale.address}
+
{residential_sale.county}
+
{residential_sale.eircode}
+
€{residential_sale.price_in_euros}
+
{residential_sale.not_full_market_price}
+
+ <% end %>
+
+
diff --git a/lib/price_register_web/endpoint.ex b/lib/ppr_api_web/endpoint.ex
similarity index 72%
rename from lib/price_register_web/endpoint.ex
rename to lib/ppr_api_web/endpoint.ex
index acb4f9d..d8f8c03 100644
--- a/lib/price_register_web/endpoint.ex
+++ b/lib/ppr_api_web/endpoint.ex
@@ -1,18 +1,19 @@
-defmodule PriceRegisterWeb.Endpoint do
- use Phoenix.Endpoint, otp_app: :price_register
- use Appsignal.Phoenix
+defmodule PprApiWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :ppr_api
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
- key: "_price_register_key",
- signing_salt: "5PAJKTfF",
+ key: "_ppr_api_key",
+ signing_salt: "PRLLA3k2",
same_site: "Lax"
]
- socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]],
+ longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
@@ -20,9 +21,9 @@ defmodule PriceRegisterWeb.Endpoint do
# when deploying your static files in production.
plug Plug.Static,
at: "/",
- from: :price_register,
+ from: :ppr_api,
gzip: false,
- only: PriceRegisterWeb.static_paths()
+ only: PprApiWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
@@ -30,7 +31,7 @@ defmodule PriceRegisterWeb.Endpoint do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
- plug Phoenix.Ecto.CheckRepoStatus, otp_app: :price_register
+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :ppr_api
end
plug Phoenix.LiveDashboard.RequestLogger,
@@ -48,5 +49,5 @@ defmodule PriceRegisterWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
- plug PriceRegisterWeb.Router
+ plug PprApiWeb.Router
end
diff --git a/lib/price_register_web/gettext.ex b/lib/ppr_api_web/gettext.ex
similarity index 60%
rename from lib/price_register_web/gettext.ex
rename to lib/ppr_api_web/gettext.ex
index 5adad40..c70d85a 100644
--- a/lib/price_register_web/gettext.ex
+++ b/lib/ppr_api_web/gettext.ex
@@ -1,11 +1,12 @@
-defmodule PriceRegisterWeb.Gettext do
+defmodule PprApiWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
- By using [Gettext](https://hexdocs.pm/gettext),
- your module gains a set of macros for translations, for example:
+ By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
+ that you can use in your application. To use this Gettext backend module,
+ call `use Gettext` and pass it as an option:
- import PriceRegisterWeb.Gettext
+ use Gettext, backend: PprApiWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
@@ -20,5 +21,5 @@ defmodule PriceRegisterWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
- use Gettext, otp_app: :price_register
+ use Gettext.Backend, otp_app: :ppr_api
end
diff --git a/lib/ppr_api_web/live/fetch_live/index.ex b/lib/ppr_api_web/live/fetch_live/index.ex
new file mode 100644
index 0000000..eec04be
--- /dev/null
+++ b/lib/ppr_api_web/live/fetch_live/index.ex
@@ -0,0 +1,28 @@
+defmodule PprApiWeb.FetchLive.Index do
+ use PprApiWeb, :live_view
+
+ alias PprApi.Fetches
+ alias Phoenix.Socket.Broadcast
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if connected?(socket) do
+ # Subscribe to a PubSub topic for "fetches" updates
+ PprApiWeb.Endpoint.subscribe("fetches_topic")
+ end
+
+ # Initial assignment
+ socket =
+ socket
+ |> assign(:fetches, Fetches.list_fetches())
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_info(%Broadcast{topic: "fetches_topic", event: "fetches_updated"}, socket) do
+ # When we receive the broadcast, re-fetch data from the DB
+ socket = assign(socket, :fetches, Fetches.list_fetches())
+ {:noreply, socket}
+ end
+end
diff --git a/lib/ppr_api_web/live/fetch_live/index.html.heex b/lib/ppr_api_web/live/fetch_live/index.html.heex
new file mode 100644
index 0000000..87ec2ea
--- /dev/null
+++ b/lib/ppr_api_web/live/fetch_live/index.html.heex
@@ -0,0 +1,6 @@
+<.table id="fetches" rows={@fetches}>
+ <:col :let={fetch} label="Status">{fetch.status}
+ <:col :let={fetch} label="Total rows">{fetch.total_rows}
+ <:col :let={fetch} label="Started at">{fetch.started_at}
+ <:col :let={fetch} label="Finished at">{fetch.finished_at}
+
diff --git a/lib/ppr_api_web/live/residential_sale_live/index.ex b/lib/ppr_api_web/live/residential_sale_live/index.ex
new file mode 100644
index 0000000..de2cfa6
--- /dev/null
+++ b/lib/ppr_api_web/live/residential_sale_live/index.ex
@@ -0,0 +1,48 @@
+defmodule PprApiWeb.ResidentialSaleLive.Index do
+ use PprApiWeb, :live_view
+ alias PprApi.ResidentialSales
+ alias PprApi.Pagination
+
+ def mount(_params, _session, socket) do
+ opts = [page: 1, per_page: 250]
+
+ %{entries: residential_sales, metadata: metadata} =
+ ResidentialSales.list_residential_sales(opts)
+
+ socket =
+ socket
+ |> assign(:residential_sales, residential_sales)
+ |> assign(:metadata, metadata)
+ |> assign(:api_path, generate_api_path(opts))
+
+ {:ok, socket}
+ end
+
+ def handle_params(params, _url, socket) do
+ opts = Pagination.parse_pagination_params(params)
+
+ %{entries: residential_sales, metadata: metadata} =
+ ResidentialSales.list_residential_sales(opts)
+
+ socket =
+ socket
+ |> assign(:residential_sales, residential_sales)
+ |> assign(:metadata, metadata)
+ |> assign(:api_path, generate_api_path(params))
+
+ {:noreply, socket}
+ end
+
+ def handle_event("nav", %{"page" => page}, socket) do
+ params = %{
+ "page" => page,
+ "per_page" => socket.assigns.metadata.per_page
+ }
+
+ {:noreply, push_patch(socket, to: ~p"/residential/sales?#{params}")}
+ end
+
+ defp generate_api_path(params) do
+ ~p"/api/v1/residential/sales?#{params}"
+ end
+end
diff --git a/lib/ppr_api_web/live/residential_sale_live/index.html.heex b/lib/ppr_api_web/live/residential_sale_live/index.html.heex
new file mode 100644
index 0000000..e5e8258
--- /dev/null
+++ b/lib/ppr_api_web/live/residential_sale_live/index.html.heex
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
Date of Sale
+
Address
+
County
+
Eircode
+
Price
+
+
+
+ <%= for residential_sale <- @residential_sales do %>
+
+ """
+
+ with_mock HTTPoison,
+ get: fn _ -> {:ok, %{body: html_response}} end do
+ assert PropertyRegister.has_the_register_been_updated_since?(~U[2022-12-31 12:00:00Z])
+ end
+ end
+
+ test "returns false when register was updated before given time" do
+ html_response = """
+
+
REGISTER LAST UPDATED - 01/01/2023 12:00:00
+
+ """
+
+ with_mock HTTPoison,
+ get: fn _ -> {:ok, %{body: html_response}} end do
+ refute PropertyRegister.has_the_register_been_updated_since?(~U[2023-01-02 12:00:00Z])
+ end
+ end
+
+ test "returns false when HTTP request fails" do
+ with_mock HTTPoison,
+ get: fn _ -> {:error, %HTTPoison.Error{reason: :timeout}} end do
+ refute PropertyRegister.has_the_register_been_updated_since?(~U[2023-01-01 12:00:00Z])
+ end
+ end
+ end
+end
diff --git a/test/ppr_api/residential_sales_test.exs b/test/ppr_api/residential_sales_test.exs
new file mode 100644
index 0000000..1588882
--- /dev/null
+++ b/test/ppr_api/residential_sales_test.exs
@@ -0,0 +1,64 @@
+defmodule PprApi.ResidentialSalesTest do
+ use PprApi.DataCase
+ import PprApi.Fixtures
+
+ alias PprApi.ResidentialSales
+
+ describe "list_residential_sales/0" do
+ test "returns all sales ordered by date" do
+ sale1 = residential_sale_fixture(%{date_of_sale: ~D[2023-01-01]})
+ sale2 = residential_sale_fixture(%{date_of_sale: ~D[2023-01-02]})
+
+ %{entries: sales, metadata: _metadata} = ResidentialSales.list_residential_sales()
+ assert [sale2.id, sale1.id] == Enum.map(sales, & &1.id)
+ end
+ end
+
+ describe "latest_sale_date/0" do
+ test "returns the most recent sale date" do
+ residential_sale_fixture(%{date_of_sale: ~D[2023-01-01]})
+ residential_sale_fixture(%{date_of_sale: ~D[2023-01-02]})
+
+ assert ResidentialSales.latest_sale_date() == ~D[2023-01-02]
+ end
+
+ test "returns nil when no sales exist" do
+ assert ResidentialSales.latest_sale_date() == nil
+ end
+ end
+
+ describe "upsert_rows/1" do
+ test "successfully inserts new records" do
+ rows = [
+ %{
+ date_of_sale: ~D[2023-01-01],
+ address: "78 The Coombe",
+ price_in_euros: Decimal.new("100000")
+ }
+ ]
+
+ count = ResidentialSales.upsert_rows(rows)
+ assert count == 1
+ end
+
+ test "handles duplicate records" do
+ existing = residential_sale_fixture()
+
+ rows = [
+ %{
+ date_of_sale: existing.date_of_sale,
+ address: existing.address,
+ price_in_euros: existing.price_in_euros
+ },
+ %{
+ date_of_sale: existing.date_of_sale,
+ address: existing.address,
+ price_in_euros: existing.price_in_euros
+ }
+ ]
+
+ count = ResidentialSales.upsert_rows(rows)
+ assert count == 1
+ end
+ end
+end
diff --git a/test/ppr_api_web/controllers/api/residential_sale_controller_test.exs b/test/ppr_api_web/controllers/api/residential_sale_controller_test.exs
new file mode 100644
index 0000000..4adb6aa
--- /dev/null
+++ b/test/ppr_api_web/controllers/api/residential_sale_controller_test.exs
@@ -0,0 +1,10 @@
+defmodule PprApiWeb.API.ResidentialSaleControllerTest do
+ use PprApiWeb.ConnCase
+
+ setup %{conn: conn} do
+ {:ok, conn: put_req_header(conn, "accept", "application/json")}
+ end
+
+ describe "index" do
+ end
+end
diff --git a/test/ppr_api_web/controllers/error_html_test.exs b/test/ppr_api_web/controllers/error_html_test.exs
new file mode 100644
index 0000000..9d75bc2
--- /dev/null
+++ b/test/ppr_api_web/controllers/error_html_test.exs
@@ -0,0 +1,14 @@
+defmodule PprApiWeb.ErrorHTMLTest do
+ use PprApiWeb.ConnCase, async: true
+
+ # Bring render_to_string/4 for testing custom views
+ import Phoenix.Template
+
+ test "renders 404.html" do
+ assert render_to_string(PprApiWeb.ErrorHTML, "404", "html", []) == "Not Found"
+ end
+
+ test "renders 500.html" do
+ assert render_to_string(PprApiWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
+ end
+end
diff --git a/test/ppr_api_web/controllers/error_json_test.exs b/test/ppr_api_web/controllers/error_json_test.exs
new file mode 100644
index 0000000..3e3c668
--- /dev/null
+++ b/test/ppr_api_web/controllers/error_json_test.exs
@@ -0,0 +1,12 @@
+defmodule PprApiWeb.ErrorJSONTest do
+ use PprApiWeb.ConnCase, async: true
+
+ test "renders 404" do
+ assert PprApiWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+ end
+
+ test "renders 500" do
+ assert PprApiWeb.ErrorJSON.render("500.json", %{}) ==
+ %{errors: %{detail: "Internal Server Error"}}
+ end
+end
diff --git a/test/ppr_api_web/controllers/fetch_controller_test.exs b/test/ppr_api_web/controllers/fetch_controller_test.exs
new file mode 100644
index 0000000..8578ee9
--- /dev/null
+++ b/test/ppr_api_web/controllers/fetch_controller_test.exs
@@ -0,0 +1,3 @@
+defmodule PprApiWeb.FetchControllerTest do
+ use PprApiWeb.ConnCase
+end
diff --git a/test/ppr_api_web/controllers/residential_sale_controller_test.exs b/test/ppr_api_web/controllers/residential_sale_controller_test.exs
new file mode 100644
index 0000000..6043550
--- /dev/null
+++ b/test/ppr_api_web/controllers/residential_sale_controller_test.exs
@@ -0,0 +1,8 @@
+defmodule PprApiWeb.ResidentialSaleControllerTest do
+ use PprApiWeb.ConnCase
+
+ describe "index" do
+ test "lists all residential_sales", %{conn: _conn} do
+ end
+ end
+end
diff --git a/test/price_register/properties_test.exs b/test/price_register/properties_test.exs
deleted file mode 100644
index d86e77d..0000000
--- a/test/price_register/properties_test.exs
+++ /dev/null
@@ -1,75 +0,0 @@
-defmodule PriceRegister.PropertiesTest do
- use PriceRegister.DataCase
-
- alias PriceRegister.Properties
-
- describe "sales" do
- alias PriceRegister.Properties.Sale
-
- import PriceRegister.PropertiesFixtures
-
- @invalid_attrs %{address: nil, county: nil, date_of_sale: nil, description_of_property: nil, eircode: nil, not_full_market_price: nil, price_in_cents: nil, property_size_description: nil, vat_exclusive: nil}
-
- test "list_sales/0 returns all sales" do
- sale = sale_fixture()
- assert Properties.list_sales() == [sale]
- end
-
- test "get_sale!/1 returns the sale with given id" do
- sale = sale_fixture()
- assert Properties.get_sale!(sale.id) == sale
- end
-
- test "create_sale/1 with valid data creates a sale" do
- valid_attrs = %{address: "some address", county: "some county", date_of_sale: ~D[2023-01-07], description_of_property: "some description_of_property", eircode: "some eircode", not_full_market_price: true, price_in_cents: 42, property_size_description: "some property_size_description", vat_exclusive: true}
-
- assert {:ok, %Sale{} = sale} = Properties.create_sale(valid_attrs)
- assert sale.address == "some address"
- assert sale.county == "some county"
- assert sale.date_of_sale == ~D[2023-01-07]
- assert sale.description_of_property == "some description_of_property"
- assert sale.eircode == "some eircode"
- assert sale.not_full_market_price == true
- assert sale.price_in_cents == 42
- assert sale.property_size_description == "some property_size_description"
- assert sale.vat_exclusive == true
- end
-
- test "create_sale/1 with invalid data returns error changeset" do
- assert {:error, %Ecto.Changeset{}} = Properties.create_sale(@invalid_attrs)
- end
-
- test "update_sale/2 with valid data updates the sale" do
- sale = sale_fixture()
- update_attrs = %{address: "some updated address", county: "some updated county", date_of_sale: ~D[2023-01-08], description_of_property: "some updated description_of_property", eircode: "some updated eircode", not_full_market_price: false, price_in_cents: 43, property_size_description: "some updated property_size_description", vat_exclusive: false}
-
- assert {:ok, %Sale{} = sale} = Properties.update_sale(sale, update_attrs)
- assert sale.address == "some updated address"
- assert sale.county == "some updated county"
- assert sale.date_of_sale == ~D[2023-01-08]
- assert sale.description_of_property == "some updated description_of_property"
- assert sale.eircode == "some updated eircode"
- assert sale.not_full_market_price == false
- assert sale.price_in_cents == 43
- assert sale.property_size_description == "some updated property_size_description"
- assert sale.vat_exclusive == false
- end
-
- test "update_sale/2 with invalid data returns error changeset" do
- sale = sale_fixture()
- assert {:error, %Ecto.Changeset{}} = Properties.update_sale(sale, @invalid_attrs)
- assert sale == Properties.get_sale!(sale.id)
- end
-
- test "delete_sale/1 deletes the sale" do
- sale = sale_fixture()
- assert {:ok, %Sale{}} = Properties.delete_sale(sale)
- assert_raise Ecto.NoResultsError, fn -> Properties.get_sale!(sale.id) end
- end
-
- test "change_sale/1 returns a sale changeset" do
- sale = sale_fixture()
- assert %Ecto.Changeset{} = Properties.change_sale(sale)
- end
- end
-end
diff --git a/test/price_register_web/controllers/error_html_test.exs b/test/price_register_web/controllers/error_html_test.exs
deleted file mode 100644
index d0b14d3..0000000
--- a/test/price_register_web/controllers/error_html_test.exs
+++ /dev/null
@@ -1,14 +0,0 @@
-defmodule PriceRegisterWeb.ErrorHTMLTest do
- use PriceRegisterWeb.ConnCase, async: true
-
- # Bring render_to_string/3 for testing custom views
- import Phoenix.Template
-
- test "renders 404.html" do
- assert render_to_string(PriceRegisterWeb.ErrorHTML, "404", "html", []) == "Not Found"
- end
-
- test "renders 500.html" do
- assert render_to_string(PriceRegisterWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
- end
-end
diff --git a/test/price_register_web/controllers/error_json_test.exs b/test/price_register_web/controllers/error_json_test.exs
deleted file mode 100644
index 858bd4a..0000000
--- a/test/price_register_web/controllers/error_json_test.exs
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule PriceRegisterWeb.ErrorJSONTest do
- use PriceRegisterWeb.ConnCase, async: true
-
- test "renders 404" do
- assert PriceRegisterWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
- end
-
- test "renders 500" do
- assert PriceRegisterWeb.ErrorJSON.render("500.json", %{}) ==
- %{errors: %{detail: "Internal Server Error"}}
- end
-end
diff --git a/test/price_register_web/controllers/page_controller_test.exs b/test/price_register_web/controllers/page_controller_test.exs
deleted file mode 100644
index 73e0796..0000000
--- a/test/price_register_web/controllers/page_controller_test.exs
+++ /dev/null
@@ -1,8 +0,0 @@
-defmodule PriceRegisterWeb.PageControllerTest do
- use PriceRegisterWeb.ConnCase
-
- test "GET /", %{conn: conn} do
- conn = get(conn, ~p"/")
- assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
- end
-end
diff --git a/test/price_register_web/controllers/sale_controller_test.exs b/test/price_register_web/controllers/sale_controller_test.exs
deleted file mode 100644
index 049e04b..0000000
--- a/test/price_register_web/controllers/sale_controller_test.exs
+++ /dev/null
@@ -1,116 +0,0 @@
-defmodule PriceRegisterWeb.SaleControllerTest do
- use PriceRegisterWeb.ConnCase
-
- import PriceRegister.PropertiesFixtures
-
- alias PriceRegister.Properties.Sale
-
- @create_attrs %{
- address: "some address",
- county: "some county",
- date_of_sale: ~D[2023-01-07],
- description_of_property: "some description_of_property",
- eircode: "some eircode",
- not_full_market_price: true,
- price_in_cents: 42,
- property_size_description: "some property_size_description",
- vat_exclusive: true
- }
- @update_attrs %{
- address: "some updated address",
- county: "some updated county",
- date_of_sale: ~D[2023-01-08],
- description_of_property: "some updated description_of_property",
- eircode: "some updated eircode",
- not_full_market_price: false,
- price_in_cents: 43,
- property_size_description: "some updated property_size_description",
- vat_exclusive: false
- }
- @invalid_attrs %{address: nil, county: nil, date_of_sale: nil, description_of_property: nil, eircode: nil, not_full_market_price: nil, price_in_cents: nil, property_size_description: nil, vat_exclusive: nil}
-
- setup %{conn: conn} do
- {:ok, conn: put_req_header(conn, "accept", "application/json")}
- end
-
- describe "index" do
- test "lists all sales", %{conn: conn} do
- conn = get(conn, ~p"/api/sales")
- assert json_response(conn, 200)["data"] == []
- end
- end
-
- describe "create sale" do
- test "renders sale when data is valid", %{conn: conn} do
- conn = post(conn, ~p"/api/sales", sale: @create_attrs)
- assert %{"id" => id} = json_response(conn, 201)["data"]
-
- conn = get(conn, ~p"/api/sales/#{id}")
-
- assert %{
- "id" => ^id,
- "address" => "some address",
- "county" => "some county",
- "date_of_sale" => "2023-01-07",
- "description_of_property" => "some description_of_property",
- "eircode" => "some eircode",
- "not_full_market_price" => true,
- "price_in_cents" => 42,
- "property_size_description" => "some property_size_description",
- "vat_exclusive" => true
- } = json_response(conn, 200)["data"]
- end
-
- test "renders errors when data is invalid", %{conn: conn} do
- conn = post(conn, ~p"/api/sales", sale: @invalid_attrs)
- assert json_response(conn, 422)["errors"] != %{}
- end
- end
-
- describe "update sale" do
- setup [:create_sale]
-
- test "renders sale when data is valid", %{conn: conn, sale: %Sale{id: id} = sale} do
- conn = put(conn, ~p"/api/sales/#{sale}", sale: @update_attrs)
- assert %{"id" => ^id} = json_response(conn, 200)["data"]
-
- conn = get(conn, ~p"/api/sales/#{id}")
-
- assert %{
- "id" => ^id,
- "address" => "some updated address",
- "county" => "some updated county",
- "date_of_sale" => "2023-01-08",
- "description_of_property" => "some updated description_of_property",
- "eircode" => "some updated eircode",
- "not_full_market_price" => false,
- "price_in_cents" => 43,
- "property_size_description" => "some updated property_size_description",
- "vat_exclusive" => false
- } = json_response(conn, 200)["data"]
- end
-
- test "renders errors when data is invalid", %{conn: conn, sale: sale} do
- conn = put(conn, ~p"/api/sales/#{sale}", sale: @invalid_attrs)
- assert json_response(conn, 422)["errors"] != %{}
- end
- end
-
- describe "delete sale" do
- setup [:create_sale]
-
- test "deletes chosen sale", %{conn: conn, sale: sale} do
- conn = delete(conn, ~p"/api/sales/#{sale}")
- assert response(conn, 204)
-
- assert_error_sent 404, fn ->
- get(conn, ~p"/api/sales/#{sale}")
- end
- end
- end
-
- defp create_sale(_) do
- sale = sale_fixture()
- %{sale: sale}
- end
-end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 5632171..4a69095 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -1,4 +1,4 @@
-defmodule PriceRegisterWeb.ConnCase do
+defmodule PprApiWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
@@ -11,7 +11,7 @@ defmodule PriceRegisterWeb.ConnCase do
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
- by setting `use PriceRegisterWeb.ConnCase, async: true`, although
+ by setting `use PprApiWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
@@ -20,19 +20,19 @@ defmodule PriceRegisterWeb.ConnCase do
using do
quote do
# The default endpoint for testing
- @endpoint PriceRegisterWeb.Endpoint
+ @endpoint PprApiWeb.Endpoint
- use PriceRegisterWeb, :verified_routes
+ use PprApiWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
- import PriceRegisterWeb.ConnCase
+ import PprApiWeb.ConnCase
end
end
setup tags do
- PriceRegister.DataCase.setup_sandbox(tags)
+ PprApi.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
index 14a9fed..e67a20d 100644
--- a/test/support/data_case.ex
+++ b/test/support/data_case.ex
@@ -1,4 +1,4 @@
-defmodule PriceRegister.DataCase do
+defmodule PprApi.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
@@ -10,7 +10,7 @@ defmodule PriceRegister.DataCase do
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
- by setting `use PriceRegister.DataCase, async: true`, although
+ by setting `use PprApi.DataCase, async: true`, although
this option is not recommended for other databases.
"""
@@ -18,17 +18,17 @@ defmodule PriceRegister.DataCase do
using do
quote do
- alias PriceRegister.Repo
+ alias PprApi.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
- import PriceRegister.DataCase
+ import PprApi.DataCase
end
end
setup tags do
- PriceRegister.DataCase.setup_sandbox(tags)
+ PprApi.DataCase.setup_sandbox(tags)
:ok
end
@@ -36,7 +36,7 @@ defmodule PriceRegister.DataCase do
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
- pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PriceRegister.Repo, shared: not tags[:async])
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PprApi.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
new file mode 100644
index 0000000..5607cfd
--- /dev/null
+++ b/test/support/fixtures.ex
@@ -0,0 +1,33 @@
+defmodule PprApi.Fixtures do
+ alias PprApi.Repo
+ alias PprApi.Fetches.Fetch
+ alias PprApi.ResidentialSales.ResidentialSale
+
+ def fetch_fixture(attrs \\ %{}) do
+ {:ok, fetch} =
+ attrs
+ |> Enum.into(%{
+ status: "starting",
+ starts_on: ~D[2023-01-01],
+ started_at: DateTime.utc_now()
+ })
+ |> then(&Fetch.changeset(%Fetch{}, &1))
+ |> Repo.insert()
+
+ fetch
+ end
+
+ def residential_sale_fixture(attrs \\ %{}) do
+ {:ok, sale} =
+ attrs
+ |> Enum.into(%{
+ date_of_sale: ~D[2023-01-01],
+ address: "123 Test St",
+ price_in_euros: Decimal.new("100000")
+ })
+ |> then(&ResidentialSale.changeset(%ResidentialSale{}, &1))
+ |> Repo.insert()
+
+ sale
+ end
+end
diff --git a/test/support/fixtures/properties_fixtures.ex b/test/support/fixtures/properties_fixtures.ex
deleted file mode 100644
index 50fdab2..0000000
--- a/test/support/fixtures/properties_fixtures.ex
+++ /dev/null
@@ -1,28 +0,0 @@
-defmodule PriceRegister.PropertiesFixtures do
- @moduledoc """
- This module defines test helpers for creating
- entities via the `PriceRegister.Properties` context.
- """
-
- @doc """
- Generate a sale.
- """
- def sale_fixture(attrs \\ %{}) do
- {:ok, sale} =
- attrs
- |> Enum.into(%{
- address: "some address",
- county: "some county",
- date_of_sale: ~D[2023-01-07],
- description_of_property: "some description_of_property",
- eircode: "some eircode",
- not_full_market_price: true,
- price_in_cents: 42,
- property_size_description: "some property_size_description",
- vat_exclusive: true
- })
- |> PriceRegister.Properties.create_sale()
-
- sale
- end
-end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 754a878..78a8fc0 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1,2 +1,2 @@
ExUnit.start()
-Ecto.Adapters.SQL.Sandbox.mode(PriceRegister.Repo, :manual)
+Ecto.Adapters.SQL.Sandbox.mode(PprApi.Repo, :manual)