new: planner v2
back to basics
This commit is contained in:
parent
a804998c4a
commit
76df992adb
150 changed files with 1736 additions and 12590 deletions
3
.flake8
Normal file
3
.flake8
Normal file
|
@ -0,0 +1,3 @@
|
|||
[flake8]
|
||||
ignore = E501
|
||||
exclude = .git,__pycache__,migrations,venv
|
179
.gitignore
vendored
179
.gitignore
vendored
|
@ -1,34 +1,165 @@
|
|||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python,vim
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python,vim
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
planner-*.tar
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# If NPM crashes, it generates a log, let's ignore it too.
|
||||
npm-debug.log
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# The directory NPM downloads your dependencies sources to.
|
||||
/assets/node_modules/
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Since we are building assets from assets/,
|
||||
# we ignore priv/static. You may want to comment
|
||||
# this depending on your deployment strategy.
|
||||
/priv/static/
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
### Vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
*~
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python,vim
|
||||
|
|
2
Procfile
2
Procfile
|
@ -1 +1 @@
|
|||
web: mix phx.server
|
||||
web: gunicorn planner.wsgi:application
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
# planner
|
||||
|
||||
this is a work-in-progress - stay tuned
|
||||
|
||||
```bash
|
||||
mix deps.get
|
||||
mix
|
||||
npm install --prefix assets
|
||||
```
|
8
app.json
8
app.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "POOL_SIZE=2 mix ecto.migrate"
|
||||
}
|
||||
"dokku": {
|
||||
"predeploy": "python manage.py migrate --noinput && python manage.py collectstatic --noinput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env"
|
||||
]
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
@import "bulma/bulma.sass";
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
margin-left: 0em !important;
|
||||
}
|
||||
|
||||
.tasks li {
|
||||
@extend .py-1, .px-5;
|
||||
|
||||
list-style: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
&:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
.value {
|
||||
@extend .mb-3;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
@extend .pointer;
|
||||
}
|
||||
|
||||
.doit {
|
||||
@extend .pointer;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #888;
|
||||
background-color: transparent;
|
||||
padding: 0rem;
|
||||
|
||||
&:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-5-5 {
|
||||
margin-left: 2rem !important;
|
||||
}
|
174
assets/js/app.js
174
assets/js/app.js
|
@ -1,174 +0,0 @@
|
|||
// We need to import the CSS so that webpack will load it.
|
||||
// The MiniCssExtractPlugin is used to separate it out into
|
||||
// its own CSS file.
|
||||
import "../css/app.scss"
|
||||
|
||||
import '@fortawesome/fontawesome-free/js/all'
|
||||
|
||||
// webpack automatically bundles all modules in your
|
||||
// entry points. Those entry points can be configured
|
||||
// in "webpack.config.js".
|
||||
//
|
||||
// Import deps with the dep name or local files with a relative path, for example:
|
||||
//
|
||||
// import {Socket} from "phoenix"
|
||||
// import socket from "./socket"
|
||||
//
|
||||
import "phoenix_html"
|
||||
import {Socket} from "phoenix"
|
||||
import LiveSocket from "phoenix_live_view"
|
||||
|
||||
let Hooks = {}
|
||||
|
||||
Hooks.Dragger = {
|
||||
toggleAddDelete() {
|
||||
const deleter = document.getElementById('deleter')
|
||||
if (deleter) {
|
||||
const adder = document.getElementById('adder')
|
||||
deleter.hidden = adder.hidden
|
||||
adder.hidden = !adder.hidden
|
||||
}
|
||||
},
|
||||
|
||||
get dragImage() {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 60
|
||||
const ctx = canvas.getContext("2d")
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(30, 30, 30, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
return canvas
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("dragstart", event => {
|
||||
event.dataTransfer.setData("text/plain", `task-id:${this.el.dataset.taskId}`)
|
||||
event.dataTransfer.setDragImage(this.dragImage, 25, 25)
|
||||
this.toggleAddDelete()
|
||||
})
|
||||
|
||||
this.el.addEventListener("dragend", event => {
|
||||
this.toggleAddDelete()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
Hooks.AddDropper = {
|
||||
get bgClass() { return "has-background-warning"},
|
||||
|
||||
addTaskToPlan(payload) { return this.pushEvent("add-task-to-plan", payload) },
|
||||
|
||||
getTaskPayload(event) { return event.dataTransfer.getData("text/plain") },
|
||||
|
||||
parseTaskPayload(payload) { return payload.startsWith("task-id:") ? payload.split(":")[1] : null },
|
||||
|
||||
addHoverClass(event) { event.target.classList.add(this.bgClass) },
|
||||
|
||||
removeHoverClass(event) { event.target.classList.remove(this.bgClass) },
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("drop", event => {
|
||||
event.preventDefault()
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) {
|
||||
const planID = this.el.dataset.planId
|
||||
this.addTaskToPlan({ "task_id": taskID, "plan_id": planID, })
|
||||
this.removeHoverClass(event)
|
||||
}
|
||||
})
|
||||
|
||||
this.el.addEventListener("dragover", event => event.preventDefault())
|
||||
|
||||
this.el.addEventListener("dragenter", event => {
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) { this.addHoverClass(event) }
|
||||
})
|
||||
|
||||
this.el.addEventListener("dragleave", event => {
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) { this.removeHoverClass(event) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Hooks.DeleteDropper = {
|
||||
get hoverBGClass() { return "has-background-warning"},
|
||||
|
||||
get baseBGClass() { return "has-background-danger"},
|
||||
|
||||
deleteTaskFromPlan(payload) { return this.pushEvent("delete-task-from-plan", payload) },
|
||||
|
||||
getTaskPayload(event) { return event.dataTransfer.getData("text/plain") },
|
||||
|
||||
parseTaskPayload(payload) { return payload.startsWith("task-id:") ? payload.split(":")[1] : null },
|
||||
|
||||
addHoverClass(event) {
|
||||
event.target.classList.add(this.hoverBGClass)
|
||||
event.target.classList.remove(this.baseBGClass)
|
||||
},
|
||||
|
||||
removeHoverClass(event) {
|
||||
event.target.classList.remove(this.hoverBGClass)
|
||||
event.target.classList.add(this.baseBGClass)
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("drop", event => {
|
||||
event.preventDefault()
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) {
|
||||
const planID = this.el.dataset.drop
|
||||
this.deleteTaskFromPlan({ "task_id": taskID, "plan_id": planID, })
|
||||
this.removeHoverClass(event)
|
||||
}
|
||||
})
|
||||
|
||||
this.el.addEventListener("dragover", event => event.preventDefault())
|
||||
|
||||
this.el.addEventListener("dragenter", event => {
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) { this.addHoverClass(event) }
|
||||
})
|
||||
|
||||
this.el.addEventListener("dragleave", event => {
|
||||
const payload = this.getTaskPayload(event)
|
||||
const taskID = this.parseTaskPayload(payload)
|
||||
if (taskID !== null) { this.removeHoverClass(event) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
|
||||
|
||||
// Connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// Expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000)
|
||||
// The latency simulator is enabled for the duration of the browser session.
|
||||
// Call disableLatencySim() to disable:
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0)
|
||||
if ($navbarBurgers.length > 0) {
|
||||
$navbarBurgers.forEach( el => {
|
||||
el.addEventListener('click', () => {
|
||||
const target = el.dataset.target
|
||||
const $target = document.getElementById(target)
|
||||
el.classList.toggle('is-active')
|
||||
$target.classList.toggle('is-active')
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
8355
assets/package-lock.json
generated
8355
assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"repository": {},
|
||||
"description": " ",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"deploy": "webpack --mode production",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.13.1",
|
||||
"bulma": "^0.9.0",
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
"phoenix_html": "file:../deps/phoenix_html",
|
||||
"phoenix_live_view": "file:../deps/phoenix_live_view"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/preset-env": "^7.0.0",
|
||||
"babel-loader": "^8.0.0",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"css-loader": "^3.4.2",
|
||||
"sass-loader": "^8.0.2",
|
||||
"node-sass": "^4.13.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.1",
|
||||
"terser-webpack-plugin": "^2.3.2",
|
||||
"webpack": "4.41.5",
|
||||
"webpack-cli": "^3.3.2"
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 14 KiB |
|
@ -1,5 +0,0 @@
|
|||
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||
#
|
||||
# To ban all spiders from the entire site uncomment the next two lines:
|
||||
# User-agent: *
|
||||
# Disallow: /
|
|
@ -1,51 +0,0 @@
|
|||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = (env, options) => {
|
||||
const devMode = options.mode !== 'production';
|
||||
|
||||
return {
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
|
||||
new OptimizeCSSAssetsPlugin({})
|
||||
]
|
||||
},
|
||||
entry: {
|
||||
'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, '../priv/static/js'),
|
||||
publicPath: '/js/'
|
||||
},
|
||||
devtool: devMode ? 'source-map' : undefined,
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader'
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.[s]?css$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
'sass-loader',
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
|
||||
new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
|
||||
]
|
||||
}
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
# This file is responsible for configuring your application
|
||||
# and its dependencies with the aid of the Mix.Config module.
|
||||
#
|
||||
# This configuration file is loaded before any dependency and
|
||||
# is restricted to this project.
|
||||
|
||||
# General application configuration
|
||||
use Mix.Config
|
||||
|
||||
config :planner,
|
||||
ecto_repos: [Planner.Repo]
|
||||
|
||||
# Configures the endpoint
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
secret_key_base: "SUNLcOWtisgdCWMIRDKt6UNgJiLmb0G/ILPVyQHYcjFQduIOoirTA9w34OZPUdfw",
|
||||
render_errors: [view: PlannerWeb.ErrorView, accepts: ~w(html json), layout: false],
|
||||
pubsub_server: Planner.PubSub,
|
||||
live_view: [signing_salt: "LL2G5/1K"]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{Mix.env()}.exs"
|
|
@ -1,76 +0,0 @@
|
|||
use Mix.Config
|
||||
|
||||
# Configure your database
|
||||
config :planner, Planner.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
database: "planner_dev",
|
||||
hostname: "localhost",
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we use it
|
||||
# with webpack to recompile .js and .css sources.
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
http: [port: 4000],
|
||||
debug_errors: true,
|
||||
code_reloader: true,
|
||||
check_origin: false,
|
||||
watchers: [
|
||||
node: [
|
||||
"node_modules/webpack/bin/webpack.js",
|
||||
"--mode",
|
||||
"development",
|
||||
"--watch-stdin",
|
||||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# In order to use HTTPS in development, a self-signed
|
||||
# certificate can be generated by running the following
|
||||
# Mix task:
|
||||
#
|
||||
# mix phx.gen.cert
|
||||
#
|
||||
# Note that this task requires Erlang/OTP 20 or later.
|
||||
# Run `mix help phx.gen.cert` for more information.
|
||||
#
|
||||
# The `http:` config above can be replaced with:
|
||||
#
|
||||
# https: [
|
||||
# port: 4001,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||
# certfile: "priv/cert/selfsigned.pem"
|
||||
# ],
|
||||
#
|
||||
# If desired, both `http:` and `https:` keys can be
|
||||
# configured to run both http and https servers on
|
||||
# different ports.
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
live_reload: [
|
||||
patterns: [
|
||||
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/planner_web/(live|views)/.*(ex)$",
|
||||
~r"lib/planner_web/templates/.*(eex)$"
|
||||
]
|
||||
]
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
|
@ -1,55 +0,0 @@
|
|||
use Mix.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,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
url: [host: "planner.thermokar.st", port: 5000],
|
||||
cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
# to the previous section and set your `:url` port to 443:
|
||||
#
|
||||
# config :planner, PlannerWeb.Endpoint,
|
||||
# ...
|
||||
# url: [host: "example.com", port: 443],
|
||||
# https: [
|
||||
# port: 443,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH"),
|
||||
# transport_options: [socket_opts: [:inet6]]
|
||||
# ]
|
||||
#
|
||||
# The `cipher_suite` is set to `:strong` to support only the
|
||||
# latest and more secure SSL ciphers. This means old browsers
|
||||
# and clients may not be supported. You can set it to
|
||||
# `:compatible` for wider support.
|
||||
#
|
||||
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||
# and cert in disk or a relative path inside priv, for example
|
||||
# "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:
|
||||
#
|
||||
# config :planner, PlannerWeb.Endpoint,
|
||||
# force_ssl: [hsts: true]
|
||||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# Finally import the config/prod.secret.exs which loads secrets
|
||||
# and configuration from environment variables.
|
||||
import_config "prod.secret.exs"
|
|
@ -1,41 +0,0 @@
|
|||
# In this file, we load production configuration and secrets
|
||||
# from environment variables. You can also hardcode secrets,
|
||||
# although such is generally not recommended and you have to
|
||||
# remember to add this file to your .gitignore.
|
||||
use Mix.Config
|
||||
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
config :planner, Planner.Repo,
|
||||
ssl: true,
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
||||
|
||||
secret_key_base =
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
http: [
|
||||
port: String.to_integer(System.get_env("PORT") || "4000"),
|
||||
transport_options: [socket_opts: [:inet6]]
|
||||
],
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# ## Using releases (Elixir v1.9+)
|
||||
#
|
||||
# If you are doing OTP releases, you need to instruct Phoenix
|
||||
# to start each relevant endpoint:
|
||||
#
|
||||
# config :planner, PlannerWeb.Endpoint, server: true
|
||||
#
|
||||
# Then you can assemble a release by calling `mix release`.
|
||||
# See `mix help release` for more information.
|
|
@ -1,25 +0,0 @@
|
|||
use Mix.Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :bcrypt_elixir, :log_rounds, 1
|
||||
|
||||
# Configure your database
|
||||
#
|
||||
# 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 :planner, Planner.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
database: "planner_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
hostname: "localhost",
|
||||
pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :planner, PlannerWeb.Endpoint,
|
||||
http: [port: 4002],
|
||||
server: false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warn
|
|
@ -1,2 +0,0 @@
|
|||
elixir_version=1.10.3
|
||||
erlang_version=21.2.5
|
|
@ -1,20 +0,0 @@
|
|||
defmodule Mix.Tasks.Planner.Register do
|
||||
use Mix.Task
|
||||
|
||||
alias Planner.Accounts
|
||||
|
||||
@shortdoc "Register a new Planner user"
|
||||
|
||||
def run([email, password]) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
case Accounts.register_user(%{email: email, password: password}) do
|
||||
{:ok, _} ->
|
||||
Mix.shell().info("User created successfully.")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
IO.inspect(changeset)
|
||||
Mix.shell().error("There was a problem.")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,9 +0,0 @@
|
|||
defmodule Planner do
|
||||
@moduledoc """
|
||||
Planner keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
Contexts are also responsible for managing your data, regardless
|
||||
if it comes from the database, an external API or others.
|
||||
"""
|
||||
end
|
|
@ -1,292 +0,0 @@
|
|||
defmodule Planner.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Planner.Repo
|
||||
alias Planner.Accounts.{User, UserToken, UserNotifier}
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
|
||||
def register_user(attrs) do
|
||||
%User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
# Inline the confirmation, for now (MRD)
|
||||
|> User.confirm_changeset()
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user e-mail.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_email(user, attrs \\ %{}) do
|
||||
User.email_changeset(user, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Emulates that the e-mail will change without actually changing
|
||||
it in the database.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> apply_user_email(user, "valid password", %{email: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> apply_user_email(user, "invalid password", %{email: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def apply_user_email(user, password, attrs) do
|
||||
user
|
||||
|> User.email_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|> Ecto.Changeset.apply_action(:update)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user e-mail in token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp user_email_multi(user, email, context) do
|
||||
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the update e-mail instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}) do
|
||||
User.password_changeset(user, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, password, attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_session_token(token) do
|
||||
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc """
|
||||
Delivers the reset password e-mail to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
|
||||
when is_function(reset_password_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user by reset password token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_reset_password_token("validtoken")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_reset_password_token("invalidtoken")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_reset_password_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
|
||||
%User{} = user <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def reset_user_password(user, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,113 +0,0 @@
|
|||
defmodule Planner.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@derive {Inspect, except: [:password]}
|
||||
schema "users" do
|
||||
field(:email, :string)
|
||||
field(:password, :string, virtual: true)
|
||||
field(:hashed_password, :string)
|
||||
field(:confirmed_at, :naive_datetime)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
|
||||
It is important to validate the length of both e-mail and password.
|
||||
Otherwise databases may truncate the e-mail without warnings, which
|
||||
could lead to unpredictable or insecure behaviour. Long passwords may
|
||||
also be very expensive to hash for certain algorithms.
|
||||
"""
|
||||
def registration_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password])
|
||||
|> validate_email()
|
||||
|> validate_password()
|
||||
end
|
||||
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|
||||
|> validate_length(:email, max: 160)
|
||||
|> unsafe_validate_unique(:email, Planner.Repo)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
|
||||
defp validate_password(changeset) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 8, max: 80)
|
||||
|> prepare_changes(&hash_password/1)
|
||||
end
|
||||
|
||||
defp hash_password(changeset) do
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
changeset
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the e-mail.
|
||||
|
||||
It requires the e-mail to change otherwise an error is added.
|
||||
"""
|
||||
def email_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email()
|
||||
|> case do
|
||||
%{changes: %{email: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :email, "did not change")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
"""
|
||||
def password_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(user) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Planner.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
def validate_current_password(changeset, password) do
|
||||
if valid_password?(changeset.data, password) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :current_password, "is not valid")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,73 +0,0 @@
|
|||
defmodule Planner.Accounts.UserNotifier do
|
||||
# For simplicity, this module simply logs messages to the terminal.
|
||||
# You should replace it by a proper e-mail or notification tool, such as:
|
||||
#
|
||||
# * Swoosh - https://hexdocs.pm/swoosh
|
||||
# * Bamboo - https://hexdocs.pm/bamboo
|
||||
#
|
||||
defp deliver(to, body) do
|
||||
require Logger
|
||||
Logger.debug(body)
|
||||
{:ok, %{to: to, body: body}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the url below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset password account.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the url below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update your e-mail.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your e-mail by visiting the url below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
|
@ -1,139 +0,0 @@
|
|||
defmodule Planner.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the reset password token expiry short,
|
||||
# since someone with access to the e-mail may take over the account.
|
||||
@reset_password_validity_in_days 1
|
||||
@confirm_validity_in_days 7
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 60
|
||||
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Planner.Accounts.User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %Planner.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token.
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token with a hashed counter part.
|
||||
|
||||
The non-hashed token is sent to the user e-mail while the
|
||||
hashed part is stored in the database, to avoid reconstruction.
|
||||
The token is valid for a week as long as users don't change
|
||||
their email.
|
||||
"""
|
||||
def build_email_token(user, context) do
|
||||
build_hashed_token(user, context, user.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(user, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%Planner.Accounts.UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
user_id: user.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token.
|
||||
"""
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from token in token_and_context_query(hashed_token, context),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("confirm"), do: @confirm_validity_in_days
|
||||
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user token record.
|
||||
"""
|
||||
def verify_change_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the given token with the given context.
|
||||
"""
|
||||
def token_and_context_query(token, context) do
|
||||
from Planner.Accounts.UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def user_and_contexts_query(user, :all) do
|
||||
from t in Planner.Accounts.UserToken, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in Planner.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
|
@ -1,34 +0,0 @@
|
|||
defmodule Planner.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Start the Ecto repository
|
||||
Planner.Repo,
|
||||
# Start the Telemetry supervisor
|
||||
PlannerWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
{Phoenix.PubSub, name: Planner.PubSub},
|
||||
# Start the Endpoint (http/https)
|
||||
PlannerWeb.Endpoint
|
||||
# Start a worker by calling: Planner.Worker.start_link(arg)
|
||||
# {Planner.Worker, arg}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Planner.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
def config_change(changed, _new, removed) do
|
||||
PlannerWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
|
@ -1,5 +0,0 @@
|
|||
defmodule Planner.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :planner,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
end
|
|
@ -1,267 +0,0 @@
|
|||
defmodule Planner.Tasks do
|
||||
import Ecto.Query
|
||||
|
||||
alias Ecto.Multi
|
||||
alias Ecto.UUID
|
||||
alias Planner.Repo
|
||||
alias Planner.Tasks.Task
|
||||
alias Planner.Tasks.Plan
|
||||
alias Planner.Tasks.PlanDetail
|
||||
|
||||
def list_unfiled_tasks("true") do
|
||||
filed_ids = from(pd in PlanDetail, select: pd.task_id)
|
||||
|
||||
from(
|
||||
t in Task,
|
||||
where: t.id not in subquery(filed_ids),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_unfiled_tasks(_done) do
|
||||
filed_ids = from(pd in PlanDetail, select: pd.task_id)
|
||||
|
||||
from(
|
||||
t in Task,
|
||||
where: is_nil(t.finished_at) and t.id not in subquery(filed_ids),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_unfinished_tasks("true") do
|
||||
from(
|
||||
t in Task,
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_unfinished_tasks(_done) do
|
||||
from(
|
||||
t in Task,
|
||||
where: is_nil(t.finished_at),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_tasks_by_plan_id("true", plan_id, task_id) do
|
||||
q =
|
||||
Ecto.Query.from(
|
||||
t in Task,
|
||||
join: pd in PlanDetail,
|
||||
on: t.id == pd.task_id,
|
||||
where: (pd.plan_id == ^plan_id)
|
||||
or
|
||||
(pd.plan_id == ^plan_id and t.id == ^task_id),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|
||||
Repo.all(q)
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_tasks_by_plan_id(_done, plan_id, task_id) do
|
||||
q =
|
||||
Ecto.Query.from(
|
||||
t in Task,
|
||||
join: pd in PlanDetail,
|
||||
on: t.id == pd.task_id,
|
||||
where: (pd.plan_id == ^plan_id and is_nil(t.finished_at))
|
||||
or
|
||||
(pd.plan_id == ^plan_id and t.id == ^task_id),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|
||||
Repo.all(q)
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def list_finished_tasks do
|
||||
from(
|
||||
t in Task,
|
||||
where: not is_nil(t.finished_at),
|
||||
order_by: [desc: t.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:plans)
|
||||
end
|
||||
|
||||
def get_task!(id), do: Repo.get!(Task, id)
|
||||
|
||||
def create_task(attrs \\ %{}) do
|
||||
%Task{}
|
||||
|> Task.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def create_task_and_add_to_plan(task_attrs, plan) do
|
||||
Multi.new()
|
||||
|> Multi.insert(:task, Task.changeset(%Task{}, task_attrs))
|
||||
|> Multi.run(:plan_detail, fn _repo, %{task: task} ->
|
||||
create_plan_detail(%{"task_id" => task.id, "plan_id" => plan.id})
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
|
||||
def update_task(%Task{} = task, attrs) do
|
||||
plans = Map.get(attrs, "plans", [])
|
||||
|
||||
new_plan_details_changesets = Enum.map(plans, fn(plan_id) ->
|
||||
PlanDetail.changeset(%PlanDetail{}, %{"task_id" => task.id, "plan_id" => plan_id})
|
||||
end)
|
||||
|
||||
deleted_plan_details =
|
||||
Ecto.Query.from(
|
||||
pd in PlanDetail,
|
||||
where: pd.task_id == ^task.id and pd.plan_id not in ^plans
|
||||
)
|
||||
|
||||
multi =
|
||||
Enum.reduce(
|
||||
new_plan_details_changesets,
|
||||
Multi.new()
|
||||
|> Multi.update(:task, Task.changeset(task, attrs))
|
||||
|> Multi.delete_all(:deleted_plan_details, deleted_plan_details),
|
||||
fn(changeset, new_multi) ->
|
||||
Multi.insert(
|
||||
new_multi,
|
||||
changeset.params["plan_id"],
|
||||
changeset,
|
||||
on_conflict: :nothing
|
||||
)
|
||||
end
|
||||
)
|
||||
|
||||
Repo.transaction(multi)
|
||||
end
|
||||
|
||||
def delete_task_by_id!(id) do
|
||||
get_task!(id)
|
||||
|> Repo.delete()
|
||||
end
|
||||
|
||||
def change_task(%Task{} = task) do
|
||||
task
|
||||
|> Task.changeset(%{})
|
||||
end
|
||||
|
||||
def task_exists?(id), do: Repo.exists?(from(t in Task, where: t.id == ^id))
|
||||
|
||||
def finish_task_by_id!(id) do
|
||||
get_task!(id)
|
||||
|> Task.finish_task()
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def unfinish_task_by_id!(id) do
|
||||
get_task!(id)
|
||||
|> Task.unfinish_task()
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def verify_task_id_from_url(task_id) do
|
||||
task_id =
|
||||
case UUID.dump(task_id) do
|
||||
# don't actually want the dumped UUID, so discard
|
||||
{:ok, _} -> task_id
|
||||
:error -> :error
|
||||
end
|
||||
|
||||
case task_id do
|
||||
:error -> :error
|
||||
_ -> task_exists?(task_id)
|
||||
end
|
||||
end
|
||||
|
||||
def list_plans do
|
||||
Repo.all(Plan)
|
||||
end
|
||||
|
||||
def list_unfinished_plans do
|
||||
from(
|
||||
p in Plan,
|
||||
where: is_nil(p.finished_at),
|
||||
order_by: [desc: p.updated_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_plan!(id), do: Repo.get!(Plan, id)
|
||||
|
||||
def create_plan(attrs \\ %{}) do
|
||||
%Plan{}
|
||||
|> Plan.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_plan(%Plan{} = plan, attrs) do
|
||||
plan
|
||||
|> Plan.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_plan(%Plan{} = plan) do
|
||||
Repo.delete(plan)
|
||||
end
|
||||
|
||||
def change_plan(%Plan{} = plan, attrs \\ %{}) do
|
||||
Plan.changeset(plan, attrs)
|
||||
end
|
||||
|
||||
def plan_exists?(id), do: Repo.exists?(from(p in Plan, where: p.id == ^id))
|
||||
|
||||
def finish_plan_by_id!(id) do
|
||||
get_plan!(id)
|
||||
|> Plan.finish_plan()
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def verify_plan_id_from_url(plan_id) do
|
||||
plan_id =
|
||||
case UUID.dump(plan_id) do
|
||||
# don't actually want the dumped UUID, so discard
|
||||
{:ok, _} -> plan_id
|
||||
:error -> :error
|
||||
end
|
||||
|
||||
case plan_id do
|
||||
:error -> :error
|
||||
_ -> plan_exists?(plan_id)
|
||||
end
|
||||
end
|
||||
|
||||
def list_plan_details do
|
||||
Repo.all(PlanDetail)
|
||||
end
|
||||
|
||||
def get_plan_detail!(id), do: Repo.get!(PlanDetail, id)
|
||||
|
||||
def get_plan_detail_by!(clauses), do: Repo.get_by!(PlanDetail, clauses)
|
||||
|
||||
def create_plan_detail(attrs \\ %{}, on_conflict \\ :nothing) do
|
||||
%PlanDetail{}
|
||||
|> PlanDetail.changeset(attrs)
|
||||
|> Repo.insert(on_conflict: on_conflict)
|
||||
end
|
||||
|
||||
def update_plan_detail(%PlanDetail{} = plan_detail, attrs) do
|
||||
plan_detail
|
||||
|> PlanDetail.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_plan_detail(%PlanDetail{} = plan_detail) do
|
||||
Repo.delete(plan_detail)
|
||||
end
|
||||
|
||||
def change_plan_detail(%PlanDetail{} = plan_detail, attrs \\ %{}) do
|
||||
PlanDetail.changeset(plan_detail, attrs)
|
||||
end
|
||||
end
|
|
@ -1,27 +0,0 @@
|
|||
defmodule Planner.Tasks.Plan do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "plans" do
|
||||
field :finished_at, :naive_datetime
|
||||
field :name, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(plan, attrs) do
|
||||
plan
|
||||
|> cast(attrs, [:name, :finished_at])
|
||||
|> validate_required([:name])
|
||||
|> update_change(:name, &String.trim/1)
|
||||
end
|
||||
|
||||
def finish_plan(plan) do
|
||||
# TODO, this should check if `finished_at` is not nil, first
|
||||
change(plan, finished_at: now())
|
||||
end
|
||||
|
||||
defp now(), do: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
end
|
|
@ -1,19 +0,0 @@
|
|||
defmodule Planner.Tasks.PlanDetail do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "plan_details" do
|
||||
field :sort, :integer
|
||||
field :task_id, :binary_id
|
||||
field :plan_id, :binary_id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(plan_detail, attrs) do
|
||||
plan_detail
|
||||
|> cast(attrs, [:sort, :task_id, :plan_id])
|
||||
end
|
||||
end
|
|
@ -1,38 +0,0 @@
|
|||
defmodule Planner.Tasks.Task do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "tasks" do
|
||||
field(:value, :string)
|
||||
field(:finished_at, :naive_datetime)
|
||||
field(:due_at, :naive_datetime)
|
||||
|
||||
many_to_many(:plans, Planner.Tasks.Plan, join_through: "plan_details", on_delete: :delete_all)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(task, attrs) do
|
||||
task
|
||||
|> cast(attrs, [:value, :finished_at, :due_at])
|
||||
|> validate_required([:value])
|
||||
|> update_change(:value, &String.trim/1)
|
||||
end
|
||||
|
||||
def finish_task(task) do
|
||||
# TODO, this should check if `finished_at` is not nil, first
|
||||
change(task, finished_at: now())
|
||||
end
|
||||
|
||||
def unfinish_task(task) do
|
||||
change(task, finished_at: nil)
|
||||
end
|
||||
|
||||
def preview(task) do
|
||||
hd(String.split(task.value, "\n"))
|
||||
end
|
||||
|
||||
defp now(), do: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
end
|
|
@ -1,103 +0,0 @@
|
|||
defmodule PlannerWeb do
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, views, channels and so on.
|
||||
|
||||
This can be used in your application as:
|
||||
|
||||
use PlannerWeb, :controller
|
||||
use PlannerWeb, :view
|
||||
|
||||
The definitions below will be executed for every view,
|
||||
controller, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define any helper function in modules
|
||||
and import those modules here.
|
||||
"""
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller, namespace: PlannerWeb
|
||||
|
||||
import Plug.Conn
|
||||
import PlannerWeb.Gettext
|
||||
import Phoenix.LiveView.Controller
|
||||
alias PlannerWeb.Router.Helpers, as: Routes
|
||||
end
|
||||
end
|
||||
|
||||
def view do
|
||||
quote do
|
||||
use Phoenix.View,
|
||||
root: "lib/planner_web/templates",
|
||||
namespace: PlannerWeb
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
|
||||
import Phoenix.LiveView.Helpers
|
||||
|
||||
# Include shared imports and aliases for views
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {PlannerWeb.LayoutView, "live.html"}
|
||||
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
import PlannerWeb.Gettext
|
||||
end
|
||||
end
|
||||
|
||||
defp view_helpers do
|
||||
quote do
|
||||
# Use all HTML functionality (forms, tags, etc)
|
||||
use Phoenix.HTML
|
||||
|
||||
# Import basic rendering functionality (render, render_layout, etc)
|
||||
import Phoenix.View
|
||||
|
||||
import PlannerWeb.ErrorHelpers
|
||||
import PlannerWeb.Gettext
|
||||
alias PlannerWeb.Router.Helpers, as: Routes
|
||||
|
||||
# Internal View Utils
|
||||
import PlannerWeb.Util
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
|
@ -1,35 +0,0 @@
|
|||
defmodule PlannerWeb.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
## Channels
|
||||
# channel "room:*", PlannerWeb.RoomChannel
|
||||
|
||||
# Socket params are passed from the client and can
|
||||
# be used to verify and authenticate a user. After
|
||||
# verification, you can put default assigns into
|
||||
# the socket that will be set for all channels, ie
|
||||
#
|
||||
# {:ok, assign(socket, :user_id, verified_user_id)}
|
||||
#
|
||||
# To deny connection, return `:error`.
|
||||
#
|
||||
# See `Phoenix.Token` documentation for examples in
|
||||
# performing token verification on connect.
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# Socket id's are topics that allow you to identify all sockets for a given user:
|
||||
#
|
||||
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
|
||||
#
|
||||
# Would allow you to broadcast a "disconnect" event and terminate
|
||||
# all active sockets and channels for a given user:
|
||||
#
|
||||
# PlannerWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
|
||||
#
|
||||
# Returning `nil` makes this socket anonymous.
|
||||
@impl true
|
||||
def id(_socket), do: nil
|
||||
end
|
|
@ -1,149 +0,0 @@
|
|||
defmodule PlannerWeb.UserAuth do
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Planner.Accounts
|
||||
alias PlannerWeb.Router.Helpers, as: Routes
|
||||
|
||||
# Make the remember me cookie valid for 60 days.
|
||||
# If you want bump or reduce this value, also change
|
||||
# the token expiry itself in UserToken.
|
||||
@max_age 60 * 60 * 24 * 60
|
||||
@remember_me_cookie "user_remember_me"
|
||||
@remember_me_options [sign: true, max_age: @max_age]
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on logout. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def login_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||
|> maybe_write_remember_me_cookie(token, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after login/logout,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn) do
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn) do
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def logout_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
PlannerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session
|
||||
and remember me token.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if user_token = get_session(conn, :user_token) do
|
||||
{user_token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if user_token = conn.cookies[@remember_me_cookie] do
|
||||
{user_token, put_session(conn, :user_token, user_token)}
|
||||
else
|
||||
{nil, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to be authenticated.
|
||||
|
||||
If you want to enforce the user e-mail is confirmed before
|
||||
they use the application at all, here would be a good place.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must login to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: Routes.user_session_path(conn, :new))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET", request_path: request_path} = conn) do
|
||||
put_session(conn, :user_return_to, request_path)
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
defp signed_in_path(_conn), do: "/"
|
||||
end
|
|
@ -1,59 +0,0 @@
|
|||
defmodule PlannerWeb.UserResetPasswordController do
|
||||
use PlannerWeb, :controller
|
||||
|
||||
alias Planner.Accounts
|
||||
|
||||
plug :get_user_by_reset_password_token when action in [:edit, :update]
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_reset_password_instructions(
|
||||
user,
|
||||
&Routes.user_reset_password_url(conn, :edit, &1)
|
||||
)
|
||||
end
|
||||
|
||||
# Regardless of the outcome, show an impartial success/error message.
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your e-mail is in our system, you will receive instructions to reset your password shortly."
|
||||
)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
|
||||
end
|
||||
|
||||
# Do not login the user after reset password to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def update(conn, %{"user" => user_params}) do
|
||||
case Accounts.reset_user_password(conn.assigns.user, user_params) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password reset successfully.")
|
||||
|> redirect(to: Routes.user_session_path(conn, :new))
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_by_reset_password_token(conn, _opts) do
|
||||
%{"token" => token} = conn.params
|
||||
|
||||
if user = Accounts.get_user_by_reset_password_token(token) do
|
||||
conn |> assign(:user, user) |> assign(:token, token)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|
||||
|> redirect(to: "/")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,26 +0,0 @@
|
|||
defmodule PlannerWeb.UserSessionController do
|
||||
use PlannerWeb, :controller
|
||||
|
||||
alias Planner.Accounts
|
||||
alias PlannerWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html", error_message: nil)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
UserAuth.login_user(conn, user, user_params)
|
||||
else
|
||||
render(conn, "new.html", error_message: "Invalid e-mail or password")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.logout_user()
|
||||
end
|
||||
end
|
|
@ -1,72 +0,0 @@
|
|||
defmodule PlannerWeb.UserSettingsController do
|
||||
use PlannerWeb, :controller
|
||||
|
||||
alias Planner.Accounts
|
||||
alias PlannerWeb.UserAuth
|
||||
|
||||
plug :assign_email_and_password_changesets
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, "edit.html")
|
||||
end
|
||||
|
||||
def update_email(conn, %{"current_password" => password, "user" => user_params}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case Accounts.apply_user_email(user, password, user_params) do
|
||||
{:ok, applied_user} ->
|
||||
Accounts.deliver_update_email_instructions(
|
||||
applied_user,
|
||||
user.email,
|
||||
&Routes.user_settings_url(conn, :confirm_email, &1)
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"A link to confirm your e-mail change has been sent to the new address."
|
||||
)
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", email_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_user, token) do
|
||||
:ok ->
|
||||
conn
|
||||
|> put_flash(:info, "E-mail changed successfully.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"current_password" => password, "user" => user_params}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password updated successfully.")
|
||||
|> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|
||||
|> UserAuth.login_user(user)
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", password_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_email_and_password_changesets(conn, _opts) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
conn
|
||||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
end
|
||||
end
|
|
@ -1,50 +0,0 @@
|
|||
defmodule PlannerWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :planner
|
||||
|
||||
# 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: "_planner_key",
|
||||
signing_salt: "Tr7ykgDL"
|
||||
]
|
||||
|
||||
socket "/socket", PlannerWeb.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phx.digest
|
||||
# when deploying your static files in production.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :planner,
|
||||
gzip: false,
|
||||
only: ~w(css fonts images js favicon.ico robots.txt)
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :planner
|
||||
end
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug PlannerWeb.Router
|
||||
end
|
|
@ -1,24 +0,0 @@
|
|||
defmodule PlannerWeb.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:
|
||||
|
||||
import PlannerWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :planner
|
||||
end
|
|
@ -1,260 +0,0 @@
|
|||
defmodule TasksComponent do
|
||||
use PlannerWeb, :live_component
|
||||
|
||||
alias Planner.Tasks
|
||||
alias Planner.Tasks.Task
|
||||
|
||||
def update(%{:changeset => changeset, :id => _id}, socket) do
|
||||
{:ok, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, Tasks.change_task(%Task{}))
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="content">
|
||||
<%= f = form_for(@changeset, "#", [phx_submit: "new-task"]) %>
|
||||
<div id="adder" class="field">
|
||||
<div class="control">
|
||||
<%= text_input(f,
|
||||
:value,
|
||||
placeholder: "add new task",
|
||||
class: "input", autocomplete: "off"
|
||||
)%>
|
||||
</div>
|
||||
<%= error_tag(f, :value) %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<%= if(!is_nil(@active_plan)) do %>
|
||||
<div
|
||||
id="deleter"
|
||||
phx-hook="DeleteDropper"
|
||||
data-drop="<%= @active_plan.id %>"
|
||||
class="has-background-danger"
|
||||
style="height: 38px; width: 100%"
|
||||
hidden=true
|
||||
></div>
|
||||
<% end %>
|
||||
|
||||
<ul class="tasks">
|
||||
<%= for task <- @tasks do %>
|
||||
<%= live_component(@socket,
|
||||
TaskComponent,
|
||||
id: "task:#{task.id}",
|
||||
task: task,
|
||||
plans: @plans,
|
||||
live_action: @live_action,
|
||||
is_active: @active_task == task.id,
|
||||
route_show_task: @route_show_task,
|
||||
route_edit_task: @route_edit_task,
|
||||
route_index_tasks: @route_index_tasks
|
||||
)%>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TaskComponent do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias Planner.Tasks.Task
|
||||
|
||||
import PlannerWeb.Util
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<li>
|
||||
<div>
|
||||
<div class="is-pulled-left">
|
||||
<%= case @task.finished_at do %>
|
||||
<% nil -> %>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
class="doit"
|
||||
phx-click="finish-task"
|
||||
phx-value-task-id="<%= @task.id %>">
|
||||
</button>
|
||||
<% _ -> %>
|
||||
<span
|
||||
class="pointer"
|
||||
phx-click="unfinish-task" phx-value-task-id="<%= @task.id %>">
|
||||
!
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="ml-5-5">
|
||||
<%= if(@is_active) do %>
|
||||
<%= case @live_action do %>
|
||||
<% :show_task -> %>
|
||||
<%= live_component(@socket,
|
||||
TaskDetailsComponent,
|
||||
id: "task_details:#{@task.id}",
|
||||
task: @task,
|
||||
route_index_tasks: @route_index_tasks,
|
||||
route_edit_task: @route_edit_task
|
||||
)%>
|
||||
<% :edit_task -> %>
|
||||
<%= live_component(@socket,
|
||||
TaskEditComponent,
|
||||
id: "task_edit:#{@task.id}",
|
||||
task: @task,
|
||||
plans: @plans
|
||||
)%>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= live_patch(to: @route_show_task.(@socket, @task.id),
|
||||
style: "display: block;"
|
||||
) do %>
|
||||
<div class="value">
|
||||
<%= md_to_html(Task.preview(@task)) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if(not is_nil(@task.due_at)) do %>
|
||||
<div class="tags mb-0">
|
||||
<span class="tag">
|
||||
due: <%= @task.due_at %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TaskDetailsComponent do
|
||||
use PlannerWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="box" id="task-details-<%= @task.id %>" draggable="true" phx-hook="Dragger" data-task-id="<%= @task.id %>">
|
||||
<%= live_patch("",
|
||||
to: @route_index_tasks.(@socket),
|
||||
class: "delete is-pulled-right"
|
||||
) %>
|
||||
<%= if(not is_nil(@task.due_at) or not is_nil(@task.finished_at) or length(@task.plans) == 0) do %>
|
||||
<div class="tags">
|
||||
<%= if(not is_nil(@task.due_at)) do %>
|
||||
<span class="tag is-warning">
|
||||
due: <%= @task.due_at %>
|
||||
</span><% end %>
|
||||
<%= if(not is_nil(@task.finished_at)) do %>
|
||||
<span class="tag is-success">
|
||||
completed
|
||||
</span><% end %>
|
||||
<%= if(length(@task.plans) == 0) do %>
|
||||
<span class="tag is-danger">
|
||||
unfiled
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mb-5">
|
||||
<%= md_to_html(@task.value) %>
|
||||
</div>
|
||||
|
||||
<div class="tags">
|
||||
<span class="tag is-light">updated: <%= @task.updated_at %></span>
|
||||
<span class="tag is-light">created: <%= @task.inserted_at %></span>
|
||||
</div>
|
||||
|
||||
<div class="buttons has-addons">
|
||||
<%= live_patch("edit",
|
||||
to: @route_edit_task.(@socket, @task.id),
|
||||
class: "button is-dark is-small"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TaskEditComponent do
|
||||
use PlannerWeb, :live_component
|
||||
|
||||
alias Planner.Tasks
|
||||
|
||||
def update(%{:changeset => changeset, :id => _id}, socket) do
|
||||
{:ok, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, Tasks.change_task(assigns.task))
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="box">
|
||||
<%= f = form_for(@changeset, "#", [phx_submit: "save-task"]) %>
|
||||
<%= hidden_input(f, :id) %>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<%= textarea(f,
|
||||
:value,
|
||||
required: true,
|
||||
class: "textarea",
|
||||
placeholder: "task",
|
||||
autocomplete: "off"
|
||||
) %>
|
||||
</div>
|
||||
<%= error_tag(f, :value) %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label(f, :due_at, class: "label") do %>
|
||||
due (YYYY-MM-DD HH:MM)
|
||||
<% end %>
|
||||
<div class="control">
|
||||
<%= text_input(f,
|
||||
:due_at,
|
||||
class: "input",
|
||||
placeholder: "YYYY-MM-DD HH:MM",
|
||||
autocomplete: "off"
|
||||
) %>
|
||||
</div>
|
||||
<%= error_tag(f, :due_at) %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">plans</label>
|
||||
<div class="control">
|
||||
<div class="select is-multiple is-dark">
|
||||
<%= multiple_select(f,
|
||||
:plans,
|
||||
Enum.map(@plans, &({&1.name, &1.id})),
|
||||
selected: Enum.map(@task.plans, &(&1.id))
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<%= submit("save", class: "button is-dark is-small") %>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -1,276 +0,0 @@
|
|||
defmodule PlannerWeb.TasksLive do
|
||||
use PlannerWeb, :live_view
|
||||
|
||||
alias Planner.Tasks
|
||||
alias Planner.Tasks.Plan
|
||||
|
||||
def mount(params, _session, socket) do
|
||||
done = Map.get(params, "done", "default_value")
|
||||
socket =
|
||||
socket
|
||||
|> assign(:plans, Tasks.list_unfinished_plans())
|
||||
|> assign(:plan_changeset, Tasks.change_plan(%Plan{}))
|
||||
|> assign(:include_done, done)
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# plan: yes, task: yes
|
||||
def handle_params(%{"plan_id" => plan_id, "task_id" => task_id} = params, _, socket) do
|
||||
case Tasks.verify_plan_id_from_url(plan_id) and Tasks.verify_task_id_from_url(task_id) do
|
||||
true ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_task, task_id)
|
||||
|> assign(:active_plan, Tasks.get_plan!(plan_id))
|
||||
|> assign(:tasks, Tasks.list_tasks_by_plan_id(socket.assigns.include_done, plan_id, task_id))
|
||||
|> add_plan_routes(plan_id)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
|
||||
end
|
||||
end
|
||||
|
||||
# plan: no, task: yes
|
||||
def handle_params(%{"task_id" => task_id} = params, _, socket) do
|
||||
case Tasks.verify_task_id_from_url(task_id) do
|
||||
true ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_task, task_id)
|
||||
|> assign(:active_plan, nil)
|
||||
|> assign(:tasks, Tasks.list_unfiled_tasks(socket.assigns.include_done))
|
||||
|> add_task_routes()
|
||||
|
||||
{:noreply, assign(socket, :active_task, task_id)}
|
||||
|
||||
_ ->
|
||||
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
|
||||
end
|
||||
end
|
||||
|
||||
# plan: yes, task: no
|
||||
def handle_params(%{"plan_id" => plan_id} = params, _, socket) do
|
||||
case Tasks.verify_plan_id_from_url(plan_id) do
|
||||
true ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_task, nil)
|
||||
|> assign(:active_plan, Tasks.get_plan!(plan_id))
|
||||
|> assign(:tasks, Tasks.list_tasks_by_plan_id(socket.assigns.include_done, plan_id, nil))
|
||||
|> add_plan_routes(plan_id)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
|
||||
end
|
||||
end
|
||||
|
||||
# plan: no, task: no
|
||||
def handle_params(params, _, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_task, nil)
|
||||
|> assign(:active_plan, nil)
|
||||
|> assign(:tasks, Tasks.list_unfiled_tasks(socket.assigns.include_done))
|
||||
|> add_task_routes()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="columns" phx-window-keydown="keydown" phx-key="Escape">
|
||||
<div class="column is-one-quarter">
|
||||
<h4 class="title is-4">plans</h4>
|
||||
<nav class="panel">
|
||||
<%= f = form_for(@plan_changeset, "#", phx_submit: "new-plan", class: "panel-block") %>
|
||||
<div class="control">
|
||||
<%= text_input(f,
|
||||
:name,
|
||||
placeholder: "add new plan",
|
||||
class: "input", autocomplete: "off"
|
||||
)%>
|
||||
<%= error_tag(f, :name) %>
|
||||
</div>
|
||||
</form>
|
||||
<%= live_patch("unfiled", to: Routes.tasks_path(@socket, :index), class: "panel-block") %>
|
||||
<%= for plan <- @plans do %>
|
||||
<%= live_patch(
|
||||
plan.name,
|
||||
to: Routes.tasks_path(@socket, :show_plan, plan.id),
|
||||
class: "panel-block",
|
||||
style: "word-break: break-all;",
|
||||
phx_hook: "AddDropper",
|
||||
data_plan_id: plan.id
|
||||
) %>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column">
|
||||
<%= case @active_plan do %>
|
||||
<%= nil -> %>
|
||||
<h4 class="title is-4">unfiled</h4>
|
||||
<% _ -> %>
|
||||
<h4 class="title is-4">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
class="doit"
|
||||
phx-click="finish-plan"
|
||||
phx-value-plan-id="<%= @active_plan.id %>">
|
||||
</button>
|
||||
|
||||
<%= @active_plan.name %>
|
||||
</h4>
|
||||
<% end %>
|
||||
<%= live_component(@socket,
|
||||
TasksComponent,
|
||||
id: :tasks,
|
||||
live_action: @live_action,
|
||||
tasks: @tasks,
|
||||
plans: @plans,
|
||||
active_plan: @active_plan,
|
||||
active_task: @active_task,
|
||||
route_show_task: @route_show_task,
|
||||
route_edit_task: @route_edit_task,
|
||||
route_index_tasks: @route_index_tasks
|
||||
)%>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("new-plan", %{"plan" => plan_params}, socket) do
|
||||
case Tasks.create_plan(plan_params) do
|
||||
{:ok, _plan} ->
|
||||
{:noreply, assign(socket, plans: Tasks.list_unfinished_plans())}
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, plan_changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("finish-plan", %{"plan-id" => plan_id}, socket) do
|
||||
{_, plan} = Tasks.finish_plan_by_id!(plan_id)
|
||||
socket = put_flash(socket, :info, "finished plan \"#{plan.name}\"")
|
||||
socket = assign(socket, plans: Tasks.list_unfinished_plans())
|
||||
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", _params, socket) do
|
||||
route = get_index_route(socket)
|
||||
|
||||
case socket.assigns.live_action do
|
||||
:index -> {:noreply, socket}
|
||||
_ -> {:noreply, push_patch(socket, to: route)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("save-task", %{"task" => task_params}, socket) do
|
||||
task = Tasks.get_task!(task_params["id"])
|
||||
|
||||
case Tasks.update_task(task, task_params) do
|
||||
{:ok, changes} ->
|
||||
# I suspect splicing in the updated task isn't much faster than just refreshing the whole list
|
||||
socket =
|
||||
socket
|
||||
|> refresh_tasks_and_flash_msg("task \"#{changes.task.value}\" updated")
|
||||
|
||||
route = get_index_route(socket)
|
||||
|
||||
{:noreply, push_patch(socket, to: route)}
|
||||
|
||||
{:error, _, changeset, _} ->
|
||||
send_update(TaskEditComponent, id: "task_edit:#{task.id}", changeset: changeset)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("finish-task", %{"task-id" => task_id}, socket) do
|
||||
{_, _} = Tasks.finish_task_by_id!(task_id)
|
||||
route = get_index_route(socket)
|
||||
{:noreply, push_patch(socket, to: route)}
|
||||
end
|
||||
|
||||
def handle_event("unfinish-task", %{"task-id" => task_id}, socket) do
|
||||
{_, _} = Tasks.unfinish_task_by_id!(task_id)
|
||||
route = get_index_route(socket)
|
||||
{:noreply, push_patch(socket, to: route)}
|
||||
end
|
||||
|
||||
def handle_event("new-task", %{"task" => task_params}, socket) do
|
||||
add_new_task(task_params, socket.assigns.active_plan, socket)
|
||||
end
|
||||
|
||||
def handle_event("add-task-to-plan", plan_detail_params, socket) do
|
||||
{_, pd} = Tasks.create_plan_detail(plan_detail_params)
|
||||
|
||||
{:noreply,
|
||||
refresh_tasks_and_flash_msg(socket, "task #{pd.task_id} added to plan #{pd.plan_id}")}
|
||||
end
|
||||
|
||||
def handle_event("delete-task-from-plan", %{"task_id" => task_id, "plan_id" => plan_id}, socket) do
|
||||
plan_detail = Tasks.get_plan_detail_by!(task_id: task_id, plan_id: plan_id)
|
||||
{_, pd} = Tasks.delete_plan_detail(plan_detail)
|
||||
|
||||
{:noreply,
|
||||
refresh_tasks_and_flash_msg(socket, "task #{pd.task_id} removed from plan #{pd.plan_id}")}
|
||||
end
|
||||
|
||||
defp refresh_tasks_and_flash_msg(socket, msg) do
|
||||
tasks =
|
||||
case socket.assigns.active_plan do
|
||||
nil -> Tasks.list_unfiled_tasks(socket.assigns.include_done)
|
||||
plan -> Tasks.list_tasks_by_plan_id(socket.assigns.include_done, plan.id, nil)
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:tasks, tasks)
|
||||
|> put_flash(:info, msg)
|
||||
end
|
||||
|
||||
defp add_new_task(task_params, _active_plan = nil, socket) do
|
||||
case Tasks.create_task(task_params) do
|
||||
{:ok, task} ->
|
||||
{:noreply, refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" created")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
send_update(TasksComponent, id: :tasks, changeset: changeset)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_new_task(task_params, active_plan, socket) do
|
||||
case Tasks.create_task_and_add_to_plan(task_params, active_plan) do
|
||||
{:ok, %{plan_detail: _, task: task}} ->
|
||||
{:noreply, refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" created")}
|
||||
|
||||
{:error, :task, %Ecto.Changeset{} = changeset, _} ->
|
||||
send_update(TasksComponent, id: :tasks, changeset: changeset)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_plan_routes(socket, plan_id) do
|
||||
socket
|
||||
|> assign(:route_show_task, &Routes.tasks_path(&1, :show_task, plan_id, &2))
|
||||
|> assign(:route_edit_task, &Routes.tasks_path(&1, :edit_task, plan_id, &2))
|
||||
|> assign(:route_index_tasks, &Routes.tasks_path(&1, :show_plan, plan_id))
|
||||
end
|
||||
|
||||
defp add_task_routes(socket) do
|
||||
socket
|
||||
|> assign(:route_show_task, &Routes.tasks_path(&1, :show_task, &2))
|
||||
|> assign(:route_edit_task, &Routes.tasks_path(&1, :edit_task, &2))
|
||||
|> assign(:route_index_tasks, &Routes.tasks_path(&1, :index))
|
||||
end
|
||||
|
||||
defp get_index_route(socket) do
|
||||
case socket.assigns.active_plan do
|
||||
nil -> Routes.tasks_path(socket, :index)
|
||||
plan -> Routes.tasks_path(socket, :show_plan, plan.id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,58 +0,0 @@
|
|||
defmodule PlannerWeb.Router do
|
||||
use PlannerWeb, :router
|
||||
|
||||
import PlannerWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug(:accepts, ["html"])
|
||||
plug(:fetch_session)
|
||||
plug(:fetch_live_flash)
|
||||
plug(:put_root_layout, {PlannerWeb.LayoutView, :root})
|
||||
plug(:protect_from_forgery)
|
||||
plug(:put_secure_browser_headers)
|
||||
plug(:fetch_current_user)
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug(:accepts, ["json"])
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", PlannerWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
scope "/", PlannerWeb do
|
||||
pipe_through([:browser, :redirect_if_user_is_authenticated])
|
||||
|
||||
get("/users/login", UserSessionController, :new)
|
||||
post("/users/login", UserSessionController, :create)
|
||||
get("/users/reset_password", UserResetPasswordController, :new)
|
||||
post("/users/reset_password", UserResetPasswordController, :create)
|
||||
get("/users/reset_password/:token", UserResetPasswordController, :edit)
|
||||
put("/users/reset_password/:token", UserResetPasswordController, :update)
|
||||
end
|
||||
|
||||
scope "/", PlannerWeb do
|
||||
pipe_through([:browser, :require_authenticated_user])
|
||||
|
||||
live("/", TasksLive, :index)
|
||||
live("/tasks", TasksLive, :index)
|
||||
live("/tasks/:task_id", TasksLive, :show_task)
|
||||
live("/tasks/:task_id/edit", TasksLive, :edit_task)
|
||||
live("/plans/:plan_id/tasks", TasksLive, :show_plan)
|
||||
live("/plans/:plan_id/tasks/:task_id", TasksLive, :show_task)
|
||||
live("/plans/:plan_id/tasks/:task_id/edit", TasksLive, :edit_task)
|
||||
|
||||
get("/users/settings", UserSettingsController, :edit)
|
||||
put("/users/settings/update_password", UserSettingsController, :update_password)
|
||||
put("/users/settings/update_email", UserSettingsController, :update_email)
|
||||
get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
|
||||
end
|
||||
|
||||
scope "/", PlannerWeb do
|
||||
pipe_through([:browser])
|
||||
|
||||
delete("/users/logout", UserSessionController, :delete)
|
||||
end
|
||||
end
|
|
@ -1,53 +0,0 @@
|
|||
defmodule PlannerWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("planner.repo.query.total_time", unit: {:native, :millisecond}),
|
||||
summary("planner.repo.query.decode_time", unit: {:native, :millisecond}),
|
||||
summary("planner.repo.query.query_time", unit: {:native, :millisecond}),
|
||||
summary("planner.repo.query.queue_time", unit: {:native, :millisecond}),
|
||||
summary("planner.repo.query.idle_time", unit: {:native, :millisecond}),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {PlannerWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
|
@ -1,38 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>planner</title>
|
||||
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||
<%= csrf_meta_tag() %>
|
||||
<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-dark mb-5" role="navigation">
|
||||
<div class="navbar-brand">
|
||||
<%= link "planner", to: Routes.tasks_path(@conn, :index), class: "navbar-item has-text-weight-bold hast-text-info-light" %>
|
||||
|
||||
<a role="button" class="navbar-burger burger" data-target="nvbr">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="nvbr" class="navbar-menu">
|
||||
<div class="navbar-start"></div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<%= link "log out", to: Routes.user_session_path(@conn, :delete), method: :delete, class: "navbar-item" %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main role="main" class="container">
|
||||
<div>
|
||||
404
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,17 +0,0 @@
|
|||
<main role="main" class="container">
|
||||
<%= if not is_nil(get_flash(@conn, :info)) do %>
|
||||
<p class="notification is-info" role="alert">
|
||||
<button class="delete" onclick="this.parentNode.remove();"></button>
|
||||
<%= get_flash(@conn, :info) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if not is_nil(get_flash(@conn, :error)) do %>
|
||||
<p class="notification is-danger" role="alert">
|
||||
<button class="delete" onclick="this.parentNode.remove();"></button>
|
||||
<%= get_flash(@conn, :error) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= @inner_content %>
|
||||
</main>
|
|
@ -1,17 +0,0 @@
|
|||
<main role="main" class="container">
|
||||
<%= if live_flash(@flash, :info) do %>
|
||||
<p class="notification is-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info">
|
||||
<button class="delete" phx-click="lv:clear-flash" phx-value-key="info"></button>
|
||||
<%= live_flash(@flash, :info) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if live_flash(@flash, :error) do %>
|
||||
<p class="notification is-danger" role="alert" phx-click="lv:clear-flash" phx-value-key="error">
|
||||
<button class="delete" phx-click="lv:clear-flash" phx-value-key="error"></button>
|
||||
<%= live_flash(@flash, :error) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= @inner_content %>
|
||||
</main>
|
|
@ -1,33 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>planner</title>
|
||||
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||
<%= csrf_meta_tag() %>
|
||||
<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-dark mb-5" role="navigation">
|
||||
<div class="navbar-brand">
|
||||
<%= link "planner", to: Routes.tasks_path(@conn, :index), class: "navbar-item has-text-weight-bold hast-text-info-light" %>
|
||||
|
||||
<a role="button" class="navbar-burger burger" data-target="nvbr">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="nvbr" class="navbar-menu">
|
||||
<div class="navbar-start"></div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<%= link "log out", to: Routes.user_session_path(@conn, :delete), method: :delete, class: "navbar-item" %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<%= @inner_content %>
|
||||
</body>
|
||||
</html>
|
|
@ -1,33 +0,0 @@
|
|||
<h1 class="title is-1">Reset password</h1>
|
||||
|
||||
<%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="help is-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :password, "New password", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :password, required: true, class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :password_confirmation, "Confirm new password", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :password_confirmation, required: true, class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= submit "Reset password", class: "button is-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Login", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
|
@ -1,18 +0,0 @@
|
|||
<h1 class="title is-1">Forgot your password?</h1>
|
||||
|
||||
<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %>
|
||||
<div class="field">
|
||||
<%= label f, :email, class: "label" %>
|
||||
<div class="control">
|
||||
<%= text_input f, :email, required: true, class: "input" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= submit "Send instructions to reset password", class: "button is-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Login", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
|
@ -1,40 +0,0 @@
|
|||
<h1 class="title is-1">Login</h1>
|
||||
|
||||
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user], fn f -> %>
|
||||
<%= if @error_message do %>
|
||||
<div class="help is-danger">
|
||||
<p><%= @error_message %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :email, class: "label" %>
|
||||
<div class="control">
|
||||
<%= text_input f, :email, required: true, class: "input" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :password, class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :password, required: true, class: "input" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<%= label class: "checkbox" do %>
|
||||
Keep me logged in for 60 days
|
||||
<%= checkbox f, :remember_me %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<%= submit "Login", class: "button is-link" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
|
||||
</p>
|
|
@ -1,71 +0,0 @@
|
|||
<h1 class="title is-1">Settings</h1>
|
||||
|
||||
<h3 class="title is-3">Change e-mail</h3>
|
||||
|
||||
<%= form_for @email_changeset, Routes.user_settings_path(@conn, :update_email), fn f -> %>
|
||||
<%= if @email_changeset.action do %>
|
||||
<div class="help is-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :email, class: "label" %>
|
||||
<div class="control">
|
||||
<%= text_input f, :email, required: true, class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :email %>
|
||||
</div<
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :current_password, for: "current_password_for_email", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= submit "Change e-mail", class: "button is-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<br>
|
||||
|
||||
<h3 class="title is-3">Change password</h3>
|
||||
|
||||
<%= form_for @password_changeset, Routes.user_settings_path(@conn, :update_password), fn f -> %>
|
||||
<%= if @password_changeset.action do %>
|
||||
<div class="help is-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :password, "New password", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :password, required: true, class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :password_confirmation, "Confirm new password", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :password_confirmation, required: true, class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= label f, :current_password, for: "current_password_for_password", class: "label" %>
|
||||
<div class="control">
|
||||
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "input" %>
|
||||
</div>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= submit "Change password", class: "button is-primary" %>
|
||||
</div>
|
||||
<% end %>
|
|
@ -1,47 +0,0 @@
|
|||
defmodule PlannerWeb.ErrorHelpers do
|
||||
@moduledoc """
|
||||
Conveniences for translating and building error messages.
|
||||
"""
|
||||
|
||||
use Phoenix.HTML
|
||||
|
||||
@doc """
|
||||
Generates tag for inlined form input errors.
|
||||
"""
|
||||
def error_tag(form, field) do
|
||||
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
||||
content_tag(:span, translate_error(error),
|
||||
class: "help is-danger",
|
||||
phx_feedback_for: input_id(form, field)
|
||||
)
|
||||
end)
|
||||
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 "is invalid" in the "errors" domain
|
||||
# dgettext("errors", "is invalid")
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# Because the error messages we show in our forms and APIs
|
||||
# are defined inside Ecto, we need to translate them dynamically.
|
||||
# This requires us to call the Gettext module passing our gettext
|
||||
# backend as first argument.
|
||||
#
|
||||
# Note we use the "errors" domain, which means translations
|
||||
# should be written to the errors.po file. The :count option is
|
||||
# set by Ecto and indicates we should also apply plural rules.
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(PlannerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(PlannerWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,16 +0,0 @@
|
|||
defmodule PlannerWeb.ErrorView do
|
||||
use PlannerWeb, :view
|
||||
|
||||
# If you want to customize a particular status code
|
||||
# for a certain format, you may uncomment below.
|
||||
# def render("500.html", _assigns) do
|
||||
# "Internal Server Error"
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def template_not_found(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.LayoutView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.PageView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.TaskView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.UserConfirmationView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.UserRegistrationView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.UserResetPasswordView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.UserSessionView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
defmodule PlannerWeb.UserSettingsView do
|
||||
use PlannerWeb, :view
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
defmodule PlannerWeb.Util do
|
||||
import Phoenix.HTML
|
||||
alias Earmark.Options
|
||||
|
||||
def md_to_html(md_text) do
|
||||
md_text
|
||||
|> Earmark.as_html!(%Options{smartypants: false})
|
||||
|> raw
|
||||
end
|
||||
end
|
22
manage.py
Executable file
22
manage.py
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'planner.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
69
mix.exs
69
mix.exs
|
@ -1,69 +0,0 @@
|
|||
defmodule Planner.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :planner,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.7",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
# Configuration for the OTP application.
|
||||
#
|
||||
# Type `mix help compile.app` for more information.
|
||||
def application do
|
||||
[
|
||||
mod: {Planner.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
]
|
||||
end
|
||||
|
||||
# Specifies which paths to compile per environment.
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# Specifies your project dependencies.
|
||||
#
|
||||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:bcrypt_elixir, "~> 2.0"},
|
||||
{:phoenix, "~> 1.5.7"},
|
||||
{:phoenix_ecto, "~> 4.1"},
|
||||
{:earmark, "~> 1.4.5"},
|
||||
{:ecto_sql, "~> 3.4"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phx_gen_auth, "~> 0.3.0", only: [:dev], runtime: false},
|
||||
{:telemetry_metrics, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 0.4"},
|
||||
{:gettext, "~> 0.11"},
|
||||
{:jason, "~> 1.0"},
|
||||
{:plug_cowboy, "~> 2.0"},
|
||||
{:phoenix_live_view, "~> 0.15.4"},
|
||||
{:floki, ">= 0.0.0", only: :test}
|
||||
]
|
||||
end
|
||||
|
||||
# Aliases are shortcuts or tasks specific to the current project.
|
||||
# For example, to install project dependencies and perform other setup tasks, run:
|
||||
#
|
||||
# $ mix setup
|
||||
#
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate", "test"]
|
||||
]
|
||||
end
|
||||
end
|
37
mix.lock
37
mix.lock
|
@ -1,37 +0,0 @@
|
|||
%{
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"},
|
||||
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},
|
||||
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
|
||||
"cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
|
||||
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
|
||||
"db_connection": {:hex, :db_connection, "2.3.0", "d56ef906956a37959bcb385704fc04035f4f43c0f560dd23e00740daf8028c49", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "dcc082b8f723de9a630451b49fdbd7a59b065c4b38176fb147aaf773574d4520"},
|
||||
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
|
||||
"earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"},
|
||||
"ecto": {:hex, :ecto, "3.5.5", "48219a991bb86daba6e38a1e64f8cea540cded58950ff38fbc8163e062281a07", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98dd0e5e1de7f45beca6130d13116eae675db59adfa055fb79612406acf6f6f1"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.1", "8faa29a5597faba999aeeb72bbb9c91694ef8068f0131192fb199f98d32994ef", [:mix], [], "hexpm", "35d33270680f8d839a4003c3e9f43afb595310a592405a00afc12de4c7f55a18"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"},
|
||||
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
|
||||
"html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
|
||||
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
|
||||
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
|
||||
"phoenix": {:hex, :phoenix, "1.5.7", "2923bb3af924f184459fe4fa4b100bd25fa6468e69b2803dfae82698269aa5e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "774cd64417c5a3788414fdbb2be2eb9bcd0c048d9e6ad11a0c1fd67b7c0d0978"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.2.4", "3080e8a89bab3ec08d4dd9a6858dfa24af9334464aae78c83e58a2db37c6f983", [:mix], [{:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.12.0 or ~> 0.13.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1c89595ef60f1b76ac07705e73f001823af451491792a4b0d5b2b2a3789b0a00"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
|
||||
"phx_gen_auth": {:hex, :phx_gen_auth, "0.3.0", "3d1f1943d7f6ecccec9a540422eec2b764d89a866e77367bc100f07f625091ef", [:mix], [{:phoenix, "~> 1.5.2", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "98d5a0a8fc34fed40c6ea9db4f57ebad6c1c50cdc0ba3aa8bc4716a5e8285990"},
|
||||
"plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"},
|
||||
"postgrex": {:hex, :postgrex, "0.15.7", "724410acd48abac529d0faa6c2a379fb8ae2088e31247687b16cacc0e0883372", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "88310c010ff047cecd73d5ceca1d99205e4b1ab1b9abfdab7e00f5c9d20ef8f9"},
|
||||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
# Node version
|
||||
node_version=10.20.1
|
0
planner/__init__.py
Normal file
0
planner/__init__.py
Normal file
16
planner/asgi.py
Normal file
16
planner/asgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for planner project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'planner.settings')
|
||||
|
||||
application = get_asgi_application()
|
0
planner/links/__init__.py
Normal file
0
planner/links/__init__.py
Normal file
6
planner/links/apps.py
Normal file
6
planner/links/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LinksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'planner.links'
|
23
planner/links/forms.py
Normal file
23
planner/links/forms.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from django import forms
|
||||
|
||||
from planner.sets.models import Set
|
||||
|
||||
from .models import Link
|
||||
|
||||
|
||||
class LinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Link
|
||||
fields = ['url', 'sets']
|
||||
|
||||
url = forms.CharField(label='', widget=forms.TextInput(attrs={'autofocus': True}))
|
||||
sets = forms.CharField(widget=forms.HiddenInput, required=False)
|
||||
|
||||
|
||||
class LinkEditForm(LinkForm):
|
||||
sets = forms.ModelMultipleChoiceField(
|
||||
label='sets',
|
||||
queryset=Set.objects.filter(done=False).order_by('-updated_at'),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(),
|
||||
)
|
24
planner/links/migrations/0001_initial.py
Normal file
24
planner/links/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 4.0 on 2022-01-07 00:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Link',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(verbose_name='URL')),
|
||||
('done', models.BooleanField(default=False, verbose_name='Done?')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
],
|
||||
),
|
||||
]
|
21
planner/links/migrations/0002_initial.py
Normal file
21
planner/links/migrations/0002_initial.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 4.0 on 2022-01-07 00:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('sets', '0001_initial'),
|
||||
('links', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='link',
|
||||
name='sets',
|
||||
field=models.ManyToManyField(related_name='sets', through='sets.SetLink', to='sets.Set'),
|
||||
),
|
||||
]
|
0
planner/links/migrations/__init__.py
Normal file
0
planner/links/migrations/__init__.py
Normal file
18
planner/links/models.py
Normal file
18
planner/links/models.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django_hashids import HashidsField
|
||||
|
||||
|
||||
_salts = settings.DJANGO_HASHID_SALTS
|
||||
|
||||
|
||||
class Link(models.Model):
|
||||
slug = HashidsField(real_field_name='id', salt=_salts['link'])
|
||||
url = models.URLField(verbose_name='URL')
|
||||
sets = models.ManyToManyField('sets.Set', through='sets.SetLink', related_name='sets')
|
||||
done = models.BooleanField(default=False, verbose_name='Done?')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Created At')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Updated At')
|
||||
|
||||
def __str__(self):
|
||||
return self.url
|
15
planner/links/urls.py
Normal file
15
planner/links/urls.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'links'
|
||||
urlpatterns = [
|
||||
path('', views.IndexView.as_view(), name='index'),
|
||||
path('done/', views.DoneListView.as_view(), name='done_list'),
|
||||
path('<str:slug>/', include([
|
||||
path('', views.LinkView.as_view(), name='detail'),
|
||||
path('edit/', views.EditView.as_view(), name='edit'),
|
||||
path('mark_as_done/', views.MarkAsDoneView.as_view(), name='mark_as_done'),
|
||||
path('mark_as_undone/', views.MarkAsUndoneView.as_view(), name='mark_as_undone'),
|
||||
])),
|
||||
]
|
72
planner/links/views.py
Normal file
72
planner/links/views.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.views.generic.edit import FormView, UpdateView
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Count, Q
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
|
||||
from planner.sets.models import Set
|
||||
|
||||
from .forms import LinkForm, LinkEditForm
|
||||
from .models import Link
|
||||
|
||||
|
||||
class IndexView(LoginRequiredMixin, FormView):
|
||||
template_name = 'links/index.html'
|
||||
form_class = LinkForm
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['links'] = Link.objects.filter(done=False, sets=None).order_by('-updated_at')
|
||||
undone_set_links = Count('links', filter=Q(links__done=False))
|
||||
ctx['sets'] = Set.objects.filter(done=False).order_by('-updated_at').annotate(undone_count=undone_set_links)
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class DoneListView(LoginRequiredMixin, ListView):
|
||||
queryset = Link.objects.filter(done=True)
|
||||
ordering = '-updated_at'
|
||||
template_name = 'links/done.html'
|
||||
context_object_name = 'links'
|
||||
|
||||
|
||||
class MarkAsDoneView(LoginRequiredMixin, DetailView):
|
||||
model = Link
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
link = self.get_object()
|
||||
if not link.done:
|
||||
link.done = True
|
||||
link.save()
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class MarkAsUndoneView(LoginRequiredMixin, DetailView):
|
||||
model = Link
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
link = self.get_object()
|
||||
if link.done:
|
||||
link.done = False
|
||||
link.save()
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class EditView(LoginRequiredMixin, UpdateView):
|
||||
model = Link
|
||||
form_class = LinkEditForm
|
||||
template_name = 'links/edit.html'
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
|
||||
class LinkView(LoginRequiredMixin, DetailView):
|
||||
model = Link
|
||||
template_name = 'links/detail.html'
|
||||
context_object_name = 'link'
|
0
planner/main/__init__.py
Normal file
0
planner/main/__init__.py
Normal file
6
planner/main/apps.py
Normal file
6
planner/main/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'planner.main'
|
5
planner/main/forms.py
Normal file
5
planner/main/forms.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class OmniForm(forms.Form):
|
||||
value = forms.CharField(label='', widget=forms.Textarea(attrs={'autofocus': True}))
|
8
planner/main/urls.py
Normal file
8
planner/main/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'main'
|
||||
urlpatterns = [
|
||||
path('', views.IndexView.as_view(), name='index'),
|
||||
]
|
39
planner/main/views.py
Normal file
39
planner/main/views.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from django.urls import reverse_lazy
|
||||
from django.views.generic.edit import FormView
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
|
||||
from planner.links.models import Link
|
||||
from planner.todos.models import Todo
|
||||
|
||||
from .forms import OmniForm
|
||||
|
||||
|
||||
class IndexView(LoginRequiredMixin, FormView):
|
||||
template_name = 'index.html'
|
||||
form_class = OmniForm
|
||||
success_url = reverse_lazy('main:index')
|
||||
|
||||
def form_valid(self, form):
|
||||
value = form.cleaned_data['value']
|
||||
url_validator = URLValidator()
|
||||
|
||||
lines = [val.strip() for val in value.splitlines()]
|
||||
|
||||
are_urls = []
|
||||
for line in lines:
|
||||
try:
|
||||
url_validator(line)
|
||||
are_urls.append(True)
|
||||
except ValidationError:
|
||||
are_urls.append(False)
|
||||
|
||||
if all(are_urls):
|
||||
Link.objects.bulk_create([Link(url=url) for url in lines])
|
||||
else:
|
||||
title = lines[0]
|
||||
body = '\n'.join(lines[1:])
|
||||
Todo.objects.create(title=title, body=body)
|
||||
|
||||
return super().form_valid(form)
|
0
planner/plans/__init__.py
Normal file
0
planner/plans/__init__.py
Normal file
6
planner/plans/apps.py
Normal file
6
planner/plans/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PlansConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'planner.plans'
|
11
planner/plans/forms.py
Normal file
11
planner/plans/forms.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django import forms
|
||||
|
||||
from .models import Plan
|
||||
|
||||
|
||||
class PlanForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Plan
|
||||
fields = ['name']
|
||||
|
||||
name = forms.CharField(label='', widget=forms.TextInput(attrs={'autofocus': True}))
|
34
planner/plans/migrations/0001_initial.py
Normal file
34
planner/plans/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 4.0 on 2022-01-06 04:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Plan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
('done', models.BooleanField(default=False, verbose_name='Done?')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PlannedTodo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plans.plan')),
|
||||
],
|
||||
),
|
||||
]
|
27
planner/plans/migrations/0002_initial.py
Normal file
27
planner/plans/migrations/0002_initial.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 4.0 on 2022-01-06 04:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('plans', '0001_initial'),
|
||||
('todos', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plannedtodo',
|
||||
name='todo',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='todos.todo'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='todos',
|
||||
field=models.ManyToManyField(related_name='todos', through='plans.PlannedTodo', to='todos.Todo'),
|
||||
),
|
||||
]
|
0
planner/plans/migrations/__init__.py
Normal file
0
planner/plans/migrations/__init__.py
Normal file
27
planner/plans/models.py
Normal file
27
planner/plans/models.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django_hashids import HashidsField
|
||||
|
||||
from planner.todos.models import Todo
|
||||
|
||||
|
||||
_salts = settings.DJANGO_HASHID_SALTS
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
slug = HashidsField(real_field_name='id', salt=_salts['plan'])
|
||||
name = models.CharField(max_length=100, verbose_name='Name')
|
||||
done = models.BooleanField(default=False, verbose_name='Done?')
|
||||
todos = models.ManyToManyField(Todo, through='PlannedTodo', related_name='todos')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Created At')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Updated At')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class PlannedTodo(models.Model):
|
||||
todo = models.ForeignKey(Todo, on_delete=models.CASCADE)
|
||||
plan = models.ForeignKey(Plan, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Created At')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Updated At')
|
16
planner/plans/urls.py
Normal file
16
planner/plans/urls.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'plans'
|
||||
urlpatterns = [
|
||||
path('new/', views.NewView.as_view(), name='new'),
|
||||
path('done/', views.DoneView.as_view(), name='done_list'),
|
||||
path('<str:slug>/', include([
|
||||
path('', views.PlanView.as_view(), name='detail'),
|
||||
path('edit/', views.EditView.as_view(), name='edit'),
|
||||
path('mark_as_done/', views.MarkAsDoneView.as_view(), name='mark_as_done'),
|
||||
path('mark_as_undone/', views.MarkAsUndoneView.as_view(), name='mark_as_undone'),
|
||||
path('todo/<str:todo_slug>/done/', views.MarkTodoAsDoneView.as_view(), name='mark_todo_as_done'),
|
||||
])),
|
||||
]
|
105
planner/plans/views.py
Normal file
105
planner/plans/views.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import FormView, UpdateView, CreateView
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from planner.todos.forms import TodoForm
|
||||
|
||||
from .forms import PlanForm
|
||||
from .models import Todo, Plan
|
||||
|
||||
|
||||
class NewView(LoginRequiredMixin, CreateView):
|
||||
model = Plan
|
||||
form_class = PlanForm
|
||||
template_name = 'plans/new.html'
|
||||
success_url = reverse_lazy('todos:index')
|
||||
|
||||
|
||||
class PlanView(LoginRequiredMixin, FormView, SingleObjectMixin):
|
||||
model = Plan
|
||||
form_class = TodoForm
|
||||
template_name = 'plans/detail.html'
|
||||
context_object_name = 'plan'
|
||||
|
||||
def get_success_url(self):
|
||||
plan = self.get_object()
|
||||
return reverse_lazy('plans:detail', args=(plan.slug,))
|
||||
|
||||
def get_initial(self):
|
||||
if getattr(self, 'object', None) is None:
|
||||
self.object = self.get_object()
|
||||
initial = super().get_initial()
|
||||
initial['plans'] = self.object.id
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object = self.get_object()
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['todos'] = self.object.todos.filter(done=False).order_by('-updated_at')
|
||||
return ctx
|
||||
|
||||
|
||||
class MarkTodoAsDoneView(LoginRequiredMixin, DetailView):
|
||||
model = Todo
|
||||
slug_url_kwarg = 'todo_slug'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
todo = self.get_object()
|
||||
if not todo.done:
|
||||
todo.done = True
|
||||
todo.save()
|
||||
plan_slug = kwargs['slug']
|
||||
url = reverse_lazy('plans:detail', args=(plan_slug,))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class MarkAsDoneView(LoginRequiredMixin, DetailView):
|
||||
model = Plan
|
||||
success_url = reverse_lazy('todos:index')
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
plan = self.get_object()
|
||||
undone_count = plan.todos.filter(done=False).count()
|
||||
|
||||
if undone_count > 0:
|
||||
messages.add_message(request, messages.INFO, 'Cannot remove plan with outstanding todos')
|
||||
|
||||
if undone_count == 0 and not plan.done:
|
||||
plan.done = True
|
||||
plan.save()
|
||||
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class MarkAsUndoneView(LoginRequiredMixin, DetailView):
|
||||
model = Plan
|
||||
success_url = reverse_lazy('todos:index')
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
plan = self.get_object()
|
||||
if plan.done:
|
||||
plan.done = False
|
||||
plan.save()
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class EditView(LoginRequiredMixin, UpdateView):
|
||||
model = Plan
|
||||
form_class = PlanForm
|
||||
template_name = 'plans/edit.html'
|
||||
success_url = reverse_lazy('todos:index')
|
||||
|
||||
|
||||
class DoneView(LoginRequiredMixin, ListView):
|
||||
queryset = Plan.objects.filter(done=True)
|
||||
ordering = '-updated_at'
|
||||
template_name = 'plans/done.html'
|
||||
context_object_name = 'plans'
|
0
planner/sets/__init__.py
Normal file
0
planner/sets/__init__.py
Normal file
6
planner/sets/apps.py
Normal file
6
planner/sets/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SetsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'planner.sets'
|
11
planner/sets/forms.py
Normal file
11
planner/sets/forms.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django import forms
|
||||
|
||||
from .models import Set
|
||||
|
||||
|
||||
class SetForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Set
|
||||
fields = ['name']
|
||||
|
||||
name = forms.CharField(label='', widget=forms.TextInput(attrs={'autofocus': True}))
|
41
planner/sets/migrations/0001_initial.py
Normal file
41
planner/sets/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 4.0 on 2022-01-07 00:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('links', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Set',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
('done', models.BooleanField(default=False, verbose_name='Done?')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SetLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='links.link')),
|
||||
('set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sets.set')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='set',
|
||||
name='links',
|
||||
field=models.ManyToManyField(related_name='links', through='sets.SetLink', to='links.Link'),
|
||||
),
|
||||
]
|
0
planner/sets/migrations/__init__.py
Normal file
0
planner/sets/migrations/__init__.py
Normal file
27
planner/sets/models.py
Normal file
27
planner/sets/models.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django_hashids import HashidsField
|
||||
|
||||
from planner.links.models import Link
|
||||
|
||||
|
||||
_salts = settings.DJANGO_HASHID_SALTS
|
||||
|
||||
|
||||
class Set(models.Model):
|
||||
slug = HashidsField(real_field_name='id', salt=_salts['set'])
|
||||
name = models.CharField(max_length=100, verbose_name='Name')
|
||||
done = models.BooleanField(default=False, verbose_name='Done?')
|
||||
links = models.ManyToManyField(Link, through='SetLink', related_name='links')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Created At')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Updated At')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class SetLink(models.Model):
|
||||
link = models.ForeignKey(Link, on_delete=models.CASCADE)
|
||||
set = models.ForeignKey(Set, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Created At')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Updated At')
|
17
planner/sets/urls.py
Normal file
17
planner/sets/urls.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'sets'
|
||||
urlpatterns = [
|
||||
path('new/', views.NewView.as_view(), name='new'),
|
||||
path('done/', views.DoneView.as_view(), name='done_list'),
|
||||
path('<str:slug>/', include([
|
||||
path('', views.SetView.as_view(), name='detail'),
|
||||
path('search/', views.SearchView.as_view(), name='search'),
|
||||
path('edit/', views.EditView.as_view(), name='edit'),
|
||||
path('mark_as_done/', views.MarkAsDoneView.as_view(), name='mark_as_done'),
|
||||
path('mark_as_undone/', views.MarkAsUndoneView.as_view(), name='mark_as_undone'),
|
||||
path('todo/<str:link_slug>/done/', views.MarkLinkAsDoneView.as_view(), name='mark_link_as_done'),
|
||||
])),
|
||||
]
|
116
planner/sets/views.py
Normal file
116
planner/sets/views.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import FormView, UpdateView, CreateView
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from planner.links.forms import LinkForm
|
||||
|
||||
from .forms import SetForm
|
||||
from .models import Link, Set
|
||||
|
||||
|
||||
class NewView(LoginRequiredMixin, CreateView):
|
||||
model = Set
|
||||
form_class = SetForm
|
||||
template_name = 'sets/new.html'
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
|
||||
class SetView(LoginRequiredMixin, FormView, SingleObjectMixin):
|
||||
model = Set
|
||||
form_class = LinkForm
|
||||
template_name = 'sets/detail.html'
|
||||
context_object_name = 'set'
|
||||
|
||||
def get_success_url(self):
|
||||
set = self.get_object()
|
||||
return reverse_lazy('sets:detail', args=(set.slug,))
|
||||
|
||||
def get_initial(self):
|
||||
if getattr(self, 'object', None) is None:
|
||||
self.object = self.get_object()
|
||||
initial = super().get_initial()
|
||||
initial['sets'] = self.object.id
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object = self.get_object()
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['links'] = self.object.links.filter(done=False).order_by('-updated_at')
|
||||
return ctx
|
||||
|
||||
|
||||
class MarkLinkAsDoneView(LoginRequiredMixin, DetailView):
|
||||
model = Link
|
||||
slug_url_kwarg = 'link_slug'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
link = self.get_object()
|
||||
if not link.done:
|
||||
link.done = True
|
||||
link.save()
|
||||
set_slug = kwargs['slug']
|
||||
url = reverse_lazy('sets:detail', args=(set_slug,))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class MarkAsDoneView(LoginRequiredMixin, DetailView):
|
||||
model = Set
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
set = self.get_object()
|
||||
undone_count = set.links.filter(done=False).count()
|
||||
|
||||
if undone_count > 0:
|
||||
messages.add_message(request, messages.INFO, 'Cannot remove set with outstanding links')
|
||||
|
||||
if undone_count == 0 and not set.done:
|
||||
set.done = True
|
||||
set.save()
|
||||
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class MarkAsUndoneView(LoginRequiredMixin, DetailView):
|
||||
model = Set
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
set = self.get_object()
|
||||
if set.done:
|
||||
set.done = False
|
||||
set.save()
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
class EditView(LoginRequiredMixin, UpdateView):
|
||||
model = Set
|
||||
form_class = SetForm
|
||||
template_name = 'sets/edit.html'
|
||||
success_url = reverse_lazy('links:index')
|
||||
|
||||
|
||||
class DoneView(LoginRequiredMixin, ListView):
|
||||
queryset = Set.objects.filter(done=True)
|
||||
ordering = '-updated_at'
|
||||
template_name = 'sets/done.html'
|
||||
context_object_name = 'sets'
|
||||
|
||||
|
||||
class SearchView(LoginRequiredMixin, DetailView):
|
||||
model = Set
|
||||
template_name = 'sets/search.html'
|
||||
context_object_name = 'set'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['links'] = self.object.links.filter(done=False).order_by('-updated_at')
|
||||
return ctx
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue