mirror of
https://github.com/mirego/accent.git
synced 2024-11-22 06:35:27 +03:00
Initial commit 💥
This commit is contained in:
commit
bca59c4caa
65
.credo.exs
Normal file
65
.credo.exs
Normal file
@ -0,0 +1,65 @@
|
||||
%{
|
||||
configs: [
|
||||
%{
|
||||
name: "default",
|
||||
strict: true,
|
||||
files: %{
|
||||
included: ["lib/", "test/", "priv/"],
|
||||
excluded: []
|
||||
},
|
||||
checks: [
|
||||
{Credo.Check.Consistency.ExceptionNames},
|
||||
{Credo.Check.Consistency.LineEndings},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators},
|
||||
{Credo.Check.Consistency.SpaceInParentheses},
|
||||
{Credo.Check.Consistency.TabsOrSpaces},
|
||||
|
||||
{Credo.Check.Design.AliasUsage, if_called_more_often_than: 2, if_nested_deeper_than: 1},
|
||||
{Credo.Check.Design.DuplicatedCode, excluded_macros: []},
|
||||
{Credo.Check.Design.TagTODO},
|
||||
{Credo.Check.Design.TagFIXME},
|
||||
|
||||
{Credo.Check.Readability.FunctionNames},
|
||||
{Credo.Check.Readability.LargeNumbers},
|
||||
{Credo.Check.Readability.MaxLineLength, max_length: 200},
|
||||
{Credo.Check.Readability.ModuleAttributeNames},
|
||||
{Credo.Check.Readability.ModuleDoc, false},
|
||||
{Credo.Check.Readability.ModuleNames},
|
||||
{Credo.Check.Readability.ParenthesesInCondition},
|
||||
{Credo.Check.Readability.PredicateFunctionNames},
|
||||
{Credo.Check.Readability.TrailingBlankLine},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace},
|
||||
{Credo.Check.Readability.VariableNames},
|
||||
|
||||
{Credo.Check.Refactor.ABCSize},
|
||||
{Credo.Check.Refactor.CaseTrivialMatches},
|
||||
{Credo.Check.Refactor.CondStatements},
|
||||
{Credo.Check.Refactor.FunctionArity},
|
||||
{Credo.Check.Refactor.MatchInCondition},
|
||||
{Credo.Check.Refactor.PipeChainStart,
|
||||
excluded_argument_types: ~w(atom binary fn keyword)a,
|
||||
excluded_functions: ~w(from)},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse},
|
||||
{Credo.Check.Refactor.Nesting},
|
||||
{Credo.Check.Refactor.UnlessWithElse},
|
||||
|
||||
{Credo.Check.Warning.IExPry},
|
||||
{Credo.Check.Warning.IoInspect, false},
|
||||
{Credo.Check.Warning.NameRedeclarationByAssignment},
|
||||
{Credo.Check.Warning.NameRedeclarationByCase},
|
||||
{Credo.Check.Warning.NameRedeclarationByDef},
|
||||
{Credo.Check.Warning.NameRedeclarationByFn},
|
||||
{Credo.Check.Warning.OperationOnSameValues},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues},
|
||||
{Credo.Check.Warning.UnusedEnumOperation},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation},
|
||||
{Credo.Check.Warning.UnusedListOperation},
|
||||
{Credo.Check.Warning.UnusedStringOperation},
|
||||
{Credo.Check.Warning.UnusedTupleOperation},
|
||||
{Credo.Check.Warning.OperationWithConstantResult},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
[
|
||||
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
|
||||
line_length: 180
|
||||
]
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# App artifacts
|
||||
/_build
|
||||
/db
|
||||
/tmp
|
||||
/doc
|
||||
/deps
|
||||
/*.ez
|
||||
/cover
|
||||
|
||||
# Generate on crash by the VM
|
||||
erl_crash.dump
|
||||
|
||||
# The config/prod.secret.exs file by default contains sensitive
|
||||
# data and you should not commit it into version control.
|
||||
#
|
||||
# Alternatively, you may comment the line below and commit the
|
||||
# secrets file as long as you replace its contents by environment
|
||||
# variables.
|
||||
/config/prod.secret.exs
|
||||
|
||||
test.json
|
||||
|
||||
.env*
|
||||
|
||||
priv/static/webapp
|
||||
/webapp/node_modules
|
5
.hanzo.yml
Normal file
5
.hanzo.yml
Normal file
@ -0,0 +1,5 @@
|
||||
remotes:
|
||||
qa: mirego-accent-api-v2-qa
|
||||
production: mirego-accent-api-v2-prod
|
||||
after_deploy:
|
||||
- mix ecto.migrate
|
43
.travis.yml
Normal file
43
.travis.yml
Normal file
@ -0,0 +1,43 @@
|
||||
# Use Travis container-based infrastructure
|
||||
sudo: false
|
||||
|
||||
# Elixir baby!
|
||||
language: 'elixir'
|
||||
|
||||
elixir: 1.6.4
|
||||
otp_release: 20.2.1
|
||||
node_js: 9.5.0
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- _build
|
||||
- deps
|
||||
|
||||
# Make sure PostgreSQL is running
|
||||
addons:
|
||||
postgresql: 9.6
|
||||
apt:
|
||||
packages:
|
||||
- libyaml-dev
|
||||
|
||||
# Set global environment variables
|
||||
env:
|
||||
global:
|
||||
- NODE_VERSION: '9.5.0'
|
||||
- MIX_ENV: 'test'
|
||||
- SECRET_KEY_BASE: 'lolwut'
|
||||
- DATABASE_URL: 'postgres://localhost/accent_web_test'
|
||||
|
||||
# Output Travis server IP for debugging
|
||||
before_install:
|
||||
- 'echo `curl --verbose http://jsonip.com`'
|
||||
- npm config set spin false
|
||||
- npm --prefix webapp install
|
||||
|
||||
# Create database and prepare the application
|
||||
before_script:
|
||||
- mix compile
|
||||
- mix ecto.setup
|
||||
|
||||
script:
|
||||
- ./priv/scripts/ci-check.sh
|
1
Aptfile
Normal file
1
Aptfile
Normal file
@ -0,0 +1 @@
|
||||
https://s3.amazonaws.com/shared.ws.mirego.com/libyaml-dev_0.1.4-3ubuntu3-2.1_amd64.deb
|
38
CODE_OF_CONDUCT.md
Normal file
38
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Code of Conduct
|
||||
|
||||
Contact: info@mirego.com
|
||||
|
||||
## Why have a Code of Conduct?
|
||||
|
||||
As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic.
|
||||
|
||||
The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about Accent effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise.
|
||||
|
||||
## Our Values
|
||||
|
||||
These are the values Accent developers should aspire to:
|
||||
|
||||
* Be friendly and welcoming
|
||||
* Be patient
|
||||
* Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.)
|
||||
* Be thoughtful
|
||||
* Productive communication requires effort. Think about how your words will be interpreted.
|
||||
* Remember that sometimes it is best to refrain entirely from commenting.
|
||||
* Be respectful
|
||||
* In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively.
|
||||
* Avoid destructive behavior
|
||||
* Derailing: stay on topic; if you want to talk about something else, start a new conversation.
|
||||
* Unconstructive criticism: don't merely decry the current state of affairs; offer (or at least solicit) suggestions as to how things may be improved.
|
||||
* Snarking (pithy, unproductive, sniping comments).
|
||||
|
||||
The following actions are explicitly forbidden:
|
||||
|
||||
* Insulting, demeaning, hateful, or threatening remarks.
|
||||
* Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic.
|
||||
* Bullying or systematic harassment.
|
||||
* Unwelcome sexual advances.
|
||||
* Incitement to any of these.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
This document was based on the Code of Conduct from the Elixir project.
|
5
Gemfile
Normal file
5
Gemfile
Normal file
@ -0,0 +1,5 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
group :development do
|
||||
gem 'hanzo'
|
||||
end
|
15
Gemfile.lock
Normal file
15
Gemfile.lock
Normal file
@ -0,0 +1,15 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
hanzo (1.0.2)
|
||||
highline (>= 1.6.19)
|
||||
highline (1.7.10)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
hanzo
|
||||
|
||||
BUNDLED WITH
|
||||
1.15.4
|
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
||||
Copyright (c) 2018, Mirego All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
Neither the name of the Mirego nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
124
README.md
Normal file
124
README.md
Normal file
@ -0,0 +1,124 @@
|
||||
<img width="160" src="logo.svg" style="padding: 0 0 30px 0;" />
|
||||
|
||||
[Website](https://www.accent.reviews) • [GraphiQL](https://www.accent.reviews/documentation)
|
||||
|
||||
[![Build Status](https://travis-ci.com/mirego/accent-web-v2.svg?token=ySqXG5pmHqKKGyP2ECxE&branch=master)](https://travis-ci.com/mirego/accent-web-v2)
|
||||
|
||||
**The first developer oriented translation tool**. Accent’s engine coupled with the asynchronous flow between the translator and the developer is what makes Accent the most awesome tool of all.
|
||||
|
||||
The Accent API provides a powerful abstraction around the process of translating and maintaing the translations of an app.
|
||||
|
||||
* **Collaboration**. Centralize your discussions around translations.
|
||||
* **History**. Full history control and actions rollback. _Who_ did _what_, _when_.
|
||||
* **UI**. Simple yet powerful UI to enable translator and developer to be productive.
|
||||
* **In-Context Editor**. Pluggable with any frontend/backend frameworks.
|
||||
* **GraphQL**. The API that powers the UI is open and documented. It’s easy to build plugin/cli/librairy around Accent.
|
||||
|
||||
_See the webapp to deploy your own Accent stack: [GitHub Repo](https://github.com/mirego/accent-webapp-v2)_
|
||||
|
||||
## Contents
|
||||
|
||||
* [Requirements](#requirements)
|
||||
* [Mix commands](#executing-mix-commands)
|
||||
* [Quickstart](#quickstart)
|
||||
* [Environment variables](#environment-variables)
|
||||
* [Tests](#tests)
|
||||
* [Heroku](#deploy-on-heroku)
|
||||
* [Contribute](#contribute)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Erlang OTP 20.1
|
||||
- Elixir 1.6.2
|
||||
- PostgreSQL >= 9.4
|
||||
- Node.js >= 8.5.0
|
||||
- libyaml
|
||||
|
||||
## Executing mix commands
|
||||
|
||||
The app is modeled with the _Twelve-Factor_ architecture, all configurations are stored in the environment.
|
||||
|
||||
When executing mix command, you should always make sure that the required system `ENV` are present. You can `source`, use [nv](https://github.com/jcouture/nv) or a custom l33t bash script.
|
||||
|
||||
Every following steps assume you have this kind of system.
|
||||
But Accent can be run with default env var if you have a PostgreSQL user named postgres listening on port 5432 on localhost.
|
||||
|
||||
### Example
|
||||
|
||||
With `nv` you inject the environment keys in the context with:
|
||||
|
||||
```shell
|
||||
> nv .env mix <mix command>
|
||||
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. If you don’t already have it, install `nodejs` with `brew install nodejs`
|
||||
1. If you don’t already have it, install `elixir` with `brew install elixir`
|
||||
2. If you don’t already have it, install `libyaml` with `brew install libyaml`
|
||||
2. If you don’t already have it, install `PostgreSQL` with `brew install postgres` or the [macOS app](https://postgresapp.com/)
|
||||
3. Install dependencies with `mix deps.get` and `npm --prefix webapp install`.
|
||||
4. Create and migrate your database with `mix ecto.setup`
|
||||
5. Start Phoenix endpoint with `mix phx.server`
|
||||
5. Start Ember server with `npm --prefix webapp run start`
|
||||
6. That’s it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
This app provides default value for every env var. This means that with the right PostgreSQL setup, you can just run `mix phx.server`.
|
||||
|
||||
- `DATABASE_URL=postgres://localhost/accent_development`: A valid database url. Like the one used by Heroku.
|
||||
- `PORT=4000`: A PORT to run your app.
|
||||
- `WEBAPP_PORT=4200`: A PORT to run your webapp. (only used in dev)
|
||||
- `API_HOST=http://localhost:4000`: The host of the API.
|
||||
- `API_WS_HOST=ws://localhost:4000`: The websocket host of the API.
|
||||
- `MIX_ENV=dev` : Environment to run mix {dev, prod, test}
|
||||
- `WEBAPP_EMAIL_HOST=localhost:8001`: Web client’s hostname. Used in the sent emails to link to the right URL. There is no default value, please provide a value if you want to send emails.
|
||||
- `MAILER_FROM=anEmail@gmail.com`: Email address used in the sent email. There is no default value, please provide a value if you want to send emails.
|
||||
|
||||
### Production setup
|
||||
|
||||
- `SENTRY_DSN`
|
||||
- `WEBAPP_SENTRY_DSN`
|
||||
- `GOOGLE_API_CLIENT_ID`: When deploying in a production env, the Google login is the only way to authenticate user. In dev, a fake login provider is used so you don’t have to setup a Google app.
|
||||
|
||||
## Tests
|
||||
|
||||
### API
|
||||
|
||||
This app provides default value for every env var required in test. This means that with the right PostgreSQL setup, you can just run `mix test`.
|
||||
|
||||
- `mix test`
|
||||
|
||||
## Deploy on Heroku
|
||||
|
||||
To successfully deploy the application on Heroku, you must use these buildpacks:
|
||||
|
||||
_The first buildpack is to use the Aptfile to install libyaml._
|
||||
|
||||
```shell
|
||||
$ heroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-apt#usr-local-paths
|
||||
$ heroku buildpacks:add --index 2 https://github.com/HashNuke/heroku-buildpack-elixir
|
||||
$ heroku buildpacks:add --index 3 https://github.com/gjaldon/heroku-buildpack-phoenix-static
|
||||
```
|
||||
|
||||
## Contribute
|
||||
|
||||
Before opening a pull request, please open an issue first.
|
||||
|
||||
```shell
|
||||
$ git clone https://github.com/mirego/accent-web-v2.git
|
||||
$ cd accent-web-v2
|
||||
$ mix deps.get
|
||||
$ mix test
|
||||
```
|
||||
|
||||
Once you've made your additions and the test suite passes, go ahead and open a PR!
|
||||
Don’t forget to run the `./priv/scripts/ci-check.sh` script to make sure that the CI will pass :)
|
||||
|
||||
## About Mirego
|
||||
|
||||
[Mirego](http://mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We're a team of [talented people](http://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](http://mirego.org).
|
||||
|
||||
We also [love open-source software](http://open.mirego.com) and we try to give back to the community as much as we can.
|
2
compile
Normal file
2
compile
Normal file
@ -0,0 +1,2 @@
|
||||
cd $phoenix_dir
|
||||
npm --prefix ./webapp run build-production
|
67
config/config.exs
Normal file
67
config/config.exs
Normal file
@ -0,0 +1,67 @@
|
||||
use Mix.Config
|
||||
|
||||
defmodule Utilities do
|
||||
def string_to_boolean("true"), do: true
|
||||
def string_to_boolean("1"), do: true
|
||||
def string_to_boolean(_), do: false
|
||||
end
|
||||
|
||||
# Configures the endpoint
|
||||
config :accent, Accent.Endpoint,
|
||||
root: Path.expand("..", __DIR__),
|
||||
http: [port: System.get_env("PORT")],
|
||||
url: [host: System.get_env("CANONICAL_HOST") || "localhost"],
|
||||
secret_key_base: System.get_env("SECRET_KEY_BASE"),
|
||||
render_errors: [accepts: ~w(json)],
|
||||
pubsub: [name: Accent.PubSub, adapter: Phoenix.PubSub.PG2]
|
||||
|
||||
# Configure your database
|
||||
config :accent, :ecto_repos, [Accent.Repo]
|
||||
|
||||
config :accent, Accent.Repo,
|
||||
adapter: Ecto.Adapters.Postgres,
|
||||
timeout: 30000,
|
||||
url: System.get_env("DATABASE_URL") || "postgres://localhost/accent_development"
|
||||
|
||||
config :phoenix, :format_encoders, "json-api": Poison
|
||||
config :phoenix, Accent.Router, host: System.get_env("CANONICAL_HOST")
|
||||
|
||||
config :accent, force_ssl: Utilities.string_to_boolean(System.get_env("FORCE_SSL"))
|
||||
|
||||
config :accent, hook_broadcaster: Accent.Hook.Broadcaster
|
||||
|
||||
config :accent, dummy_provider_enabled: true
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
config :canary,
|
||||
repo: Accent.Repo,
|
||||
unauthorized_handler: {Accent.ErrorController, :handle_unauthorized},
|
||||
not_found_handler: {Accent.ErrorController, :handle_not_found}
|
||||
|
||||
config :sentry,
|
||||
dsn: System.get_env("SENTRY_DSN"),
|
||||
included_environments: [:prod],
|
||||
environment_name: Mix.env(),
|
||||
root_source_code_path: File.cwd!()
|
||||
|
||||
# Used to extract schema json with the absinthe’s mix task
|
||||
config :absinthe, :schema, Accent.GraphQL.Schema
|
||||
|
||||
# Configure phoenix generators
|
||||
config :phoenix, :generators,
|
||||
migration: true,
|
||||
binary_id: false
|
||||
|
||||
config :mime, :types, %{
|
||||
"application/vnd.api+json" => ["json-api"]
|
||||
}
|
||||
|
||||
import_config "mailer.exs"
|
||||
|
||||
# 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"
|
22
config/dev.exs
Normal file
22
config/dev.exs
Normal file
@ -0,0 +1,22 @@
|
||||
use Mix.Config
|
||||
|
||||
# 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 brunch.io to recompile .js and .css sources.
|
||||
config :accent, Accent.Endpoint,
|
||||
debug_errors: true,
|
||||
code_reloader: true,
|
||||
cache_static_lookup: false,
|
||||
check_origin: false,
|
||||
watchers: []
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development.
|
||||
# Do not configure such in production as keeping
|
||||
# and calculating stacktraces is usually expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
18
config/mailer.exs
Normal file
18
config/mailer.exs
Normal file
@ -0,0 +1,18 @@
|
||||
use Mix.Config
|
||||
|
||||
if System.get_env("SMTP_ADDRESS") do
|
||||
config :accent, Accent.Mailer,
|
||||
webapp_host: System.get_env("WEBAPP_EMAIL_HOST"),
|
||||
mailer_from: System.get_env("MAILER_FROM"),
|
||||
adapter: Bamboo.SMTPAdapter,
|
||||
server: System.get_env("SMTP_ADDRESS"),
|
||||
port: System.get_env("SMTP_PORT"),
|
||||
username: System.get_env("SMTP_USERNAME"),
|
||||
password: System.get_env("SMTP_PASSWORD"),
|
||||
x_smtpapi_header: System.get_env("SMTP_API_HEADER")
|
||||
else
|
||||
config :accent, Accent.Mailer,
|
||||
webapp_host: System.get_env("WEBAPP_EMAIL_HOST"),
|
||||
mailer_from: System.get_env("MAILER_FROM"),
|
||||
adapter: Bamboo.LocalAdapter
|
||||
end
|
6
config/prod.exs
Normal file
6
config/prod.exs
Normal file
@ -0,0 +1,6 @@
|
||||
use Mix.Config
|
||||
|
||||
config :accent, Accent.Endpoint, check_origin: false
|
||||
config :accent, dummy_provider_enabled: false
|
||||
|
||||
config :logger, level: :info
|
26
config/test.exs
Normal file
26
config/test.exs
Normal file
@ -0,0 +1,26 @@
|
||||
use Mix.Config
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :accent, Accent.Endpoint,
|
||||
http: [port: 4001],
|
||||
server: false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warn
|
||||
|
||||
# Configure your database
|
||||
config :accent, sql_sandbox: true
|
||||
|
||||
config :accent, Accent.Repo,
|
||||
adapter: Ecto.Adapters.Postgres,
|
||||
url: System.get_env("DATABASE_URL") || "postgres://localhost/accent_test",
|
||||
pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
config :accent, Accent.Mailer,
|
||||
webapp_host: "http://example.com",
|
||||
mailer_from: "accent-test@example.com",
|
||||
x_smtpapi_header: ~s({"category": ["test", "accent-api-test"]}),
|
||||
adapter: Bamboo.LocalAdapter
|
||||
|
||||
config :accent, hook_broadcaster: Accent.Hook.BroadcasterMock
|
11
elixir_buildpack.config
Normal file
11
elixir_buildpack.config
Normal file
@ -0,0 +1,11 @@
|
||||
# Erlang version
|
||||
erlang_version=20.2.1
|
||||
|
||||
# Elixir version
|
||||
elixir_version=1.6.4
|
||||
|
||||
# Always rebuild from scratch on every deploy?
|
||||
always_rebuild=false
|
||||
|
||||
# Export heroku config vars
|
||||
config_vars_to_export=(PORT DATABASE_URL SECRET_KEY_BASE CANONICAL_HOST)
|
36
lib/accent.ex
Normal file
36
lib/accent.ex
Normal file
@ -0,0 +1,36 @@
|
||||
defmodule Accent do
|
||||
use Application
|
||||
|
||||
# See http://elixir-lang.org/docs/stable/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
def start(_type, _args) do
|
||||
import Supervisor.Spec, warn: false
|
||||
|
||||
children = [
|
||||
# Start the endpoint when the application starts
|
||||
supervisor(Accent.Endpoint, []),
|
||||
# Start the Ecto repository
|
||||
worker(Accent.Repo, []),
|
||||
worker(Accent.Hook.Producers.Email, []),
|
||||
worker(Accent.Hook.Consumers.Email, []),
|
||||
worker(Accent.Hook.Producers.Websocket, []),
|
||||
worker(Accent.Hook.Consumers.Websocket, []),
|
||||
worker(Accent.Hook.Producers.Slack, []),
|
||||
worker(Accent.Hook.Consumers.Slack, http_client: HTTPoison)
|
||||
]
|
||||
|
||||
:ok = :error_logger.add_report_handler(Sentry.Logger)
|
||||
|
||||
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Accent.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
|
||||
Accent.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
23
lib/accent/auth/canada_implementations.ex
Normal file
23
lib/accent/auth/canada_implementations.ex
Normal file
@ -0,0 +1,23 @@
|
||||
defimpl Canada.Can, for: Accent.User do
|
||||
alias Accent.{User, Project, Revision}
|
||||
|
||||
def can?(_user, _action, nil), do: false
|
||||
|
||||
def can?(%User{permissions: permissions}, action, project_id) when is_binary(project_id) do
|
||||
validate_role(permissions, action, project_id)
|
||||
end
|
||||
|
||||
def can?(%User{permissions: permissions}, action, %Project{id: project_id}) when is_binary(project_id) do
|
||||
validate_role(permissions, action, project_id)
|
||||
end
|
||||
|
||||
def can?(%User{permissions: permissions}, action, %Revision{project_id: project_id}) when is_binary(project_id) do
|
||||
validate_role(permissions, action, project_id)
|
||||
end
|
||||
|
||||
def validate_role(permissions, action, project_id) do
|
||||
permissions
|
||||
|> Map.get(project_id)
|
||||
|> Accent.RoleAbilities.can?(action)
|
||||
end
|
||||
end
|
99
lib/accent/auth/role_abilities.ex
Normal file
99
lib/accent/auth/role_abilities.ex
Normal file
@ -0,0 +1,99 @@
|
||||
defmodule Accent.RoleAbilities do
|
||||
@owner_role "owner"
|
||||
@admin_role "admin"
|
||||
@bot_role "bot"
|
||||
@developer_role "developer"
|
||||
@reviewer_role "reviewer"
|
||||
|
||||
@any_actions ~w(
|
||||
index_permissions
|
||||
index_versions
|
||||
create_version
|
||||
update_version
|
||||
export_version
|
||||
index_translations
|
||||
index_comments
|
||||
create_comment
|
||||
show_comment
|
||||
correct_all_revision
|
||||
uncorrect_all_revision
|
||||
show_project
|
||||
index_collaborators
|
||||
correct_translation
|
||||
uncorrect_translation
|
||||
update_translation
|
||||
show_translation
|
||||
index_project_activities
|
||||
index_related_translations
|
||||
index_revisions
|
||||
show_revision
|
||||
index_documents
|
||||
show_document
|
||||
show_activity
|
||||
index_translation_activities
|
||||
index_translation_comments_subscriptions
|
||||
create_translation_comments_subscription
|
||||
delete_translation_comments_subscription
|
||||
)a
|
||||
|
||||
@bot_actions ~w(
|
||||
peek_sync
|
||||
peek_merge
|
||||
merge
|
||||
sync
|
||||
)a ++ @any_actions
|
||||
|
||||
@developer_actions ~w(
|
||||
peek_sync
|
||||
peek_merge
|
||||
merge
|
||||
sync
|
||||
delete_document
|
||||
show_project_access_token
|
||||
index_integrations
|
||||
create_integration
|
||||
update_integration
|
||||
delete_integration
|
||||
)a ++ @any_actions
|
||||
|
||||
@admin_actions ~w(
|
||||
create_slave
|
||||
delete_slave
|
||||
promote_slave
|
||||
update_project
|
||||
delete_collaborator
|
||||
create_collaborator
|
||||
update_collaborator
|
||||
rollback
|
||||
lock_project_file_operations
|
||||
delete_project
|
||||
)a ++ @developer_actions
|
||||
|
||||
def actions_for(@owner_role), do: @admin_actions
|
||||
def actions_for(@admin_role), do: @admin_actions
|
||||
def actions_for(@bot_role), do: @bot_actions
|
||||
def actions_for(@developer_role), do: @developer_actions
|
||||
def actions_for(@reviewer_role), do: @any_actions
|
||||
|
||||
# Define abilities function at compile time to remove list lookup at runtime
|
||||
def can?(@owner_role, _action), do: true
|
||||
|
||||
for action <- @admin_actions do
|
||||
def can?(@admin_role, unquote(action)), do: true
|
||||
end
|
||||
|
||||
for action <- @bot_actions do
|
||||
def can?(@bot_role, unquote(action)), do: true
|
||||
end
|
||||
|
||||
for action <- @developer_actions do
|
||||
def can?(@developer_role, unquote(action)), do: true
|
||||
end
|
||||
|
||||
for action <- @any_actions do
|
||||
def can?(@reviewer_role, unquote(action)), do: true
|
||||
end
|
||||
|
||||
# Fallback if no permission has been found for the user on the project
|
||||
def can?(_role, _action), do: false
|
||||
end
|
49
lib/accent/auth/user_auth_fetcher.ex
Normal file
49
lib/accent/auth/user_auth_fetcher.ex
Normal file
@ -0,0 +1,49 @@
|
||||
defmodule Accent.UserAuthFetcher do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Accent.{
|
||||
Repo,
|
||||
Collaborator,
|
||||
User
|
||||
}
|
||||
|
||||
@doc """
|
||||
fetch the associated user. It also fetches the permissions
|
||||
"""
|
||||
@spec fetch(String.t() | nil) :: User.t() | nil
|
||||
def fetch(access_token) do
|
||||
access_token
|
||||
|> fetch_user
|
||||
|> map_permissions
|
||||
end
|
||||
|
||||
defp fetch_user("Bearer " <> token) when is_binary(token) do
|
||||
from(
|
||||
user in User,
|
||||
left_join: access_token in assoc(user, :access_tokens),
|
||||
where: access_token.token == ^token,
|
||||
where: is_nil(access_token.revoked_at)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp fetch_user(_any), do: nil
|
||||
|
||||
defp map_permissions(nil), do: nil
|
||||
|
||||
defp map_permissions(user) do
|
||||
permissions =
|
||||
from(
|
||||
collaborator in Collaborator,
|
||||
where: [user_id: ^user.id],
|
||||
select: %{project_id: collaborator.project_id, role: collaborator.role}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.reduce(Map.new(), fn %{project_id: project_id, role: role}, acc ->
|
||||
Map.put(acc, project_id, role)
|
||||
end)
|
||||
|
||||
user
|
||||
|> Map.put(:permissions, permissions)
|
||||
end
|
||||
end
|
5
lib/accent/auth/user_remote/adapter/fetcher.ex
Normal file
5
lib/accent/auth/user_remote/adapter/fetcher.ex
Normal file
@ -0,0 +1,5 @@
|
||||
defmodule Accent.UserRemote.Adapter.Fetcher do
|
||||
alias Accent.UserRemote.Adapter.User
|
||||
|
||||
@callback fetch(String.t()) :: {:ok, User.t()} | {:error, list(String.t())}
|
||||
end
|
5
lib/accent/auth/user_remote/adapter/user.ex
Normal file
5
lib/accent/auth/user_remote/adapter/user.ex
Normal file
@ -0,0 +1,5 @@
|
||||
defmodule Accent.UserRemote.Adapter.User do
|
||||
defstruct ~w(email provider uid fullname picture_url)a
|
||||
|
||||
@type t :: %__MODULE__{email: String.t(), provider: String.t(), uid: String.t(), fullname: String.t(), picture_url: String.t()}
|
||||
end
|
17
lib/accent/auth/user_remote/adapters/dummy.ex
Normal file
17
lib/accent/auth/user_remote/adapters/dummy.ex
Normal file
@ -0,0 +1,17 @@
|
||||
if Application.get_env(:accent, :dummy_provider_enabled) do
|
||||
defmodule Accent.UserRemote.Adapters.Dummy do
|
||||
@moduledoc """
|
||||
This is the simplest adapter for user remote fetching.
|
||||
|
||||
It simply returns the value as both the email and the uid.
|
||||
"""
|
||||
|
||||
@behaviour Accent.UserRemote.Adapter.Fetcher
|
||||
@name "dummy"
|
||||
|
||||
alias Accent.UserRemote.Adapter.User
|
||||
|
||||
def fetch(value) when value === "", do: {:error, ["invalid email"]}
|
||||
def fetch(value), do: {:ok, %User{email: String.downcase(value), provider: @name, uid: value}}
|
||||
end
|
||||
end
|
39
lib/accent/auth/user_remote/adapters/google.ex
Normal file
39
lib/accent/auth/user_remote/adapters/google.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule Accent.UserRemote.Adapters.Google do
|
||||
@moduledoc """
|
||||
Fetches the email and the uid from the id_token
|
||||
using the Google API v3 token info endpoint.
|
||||
"""
|
||||
|
||||
@behaviour Accent.UserRemote.Adapter.Fetcher
|
||||
@name "google"
|
||||
|
||||
alias Accent.UserRemote.Adapter.User
|
||||
|
||||
defmodule TokenInfoClient do
|
||||
use HTTPoison.Base
|
||||
|
||||
@base_url "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token="
|
||||
|
||||
def process_url(token), do: @base_url <> token
|
||||
|
||||
def process_response_body(body), do: Poison.decode!(body)
|
||||
end
|
||||
|
||||
def fetch(token) when token === "", do: {:error, "invalid token"}
|
||||
|
||||
def fetch(token) do
|
||||
token
|
||||
|> TokenInfoClient.get()
|
||||
|> case do
|
||||
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
|
||||
email = String.downcase(body["email"])
|
||||
{:ok, %User{provider: @name, fullname: body["name"], picture_url: body["picture"], email: email, uid: email}}
|
||||
|
||||
{:ok, %HTTPoison.Response{status_code: 400}} ->
|
||||
{:error, "invalid token"}
|
||||
|
||||
{:error, %HTTPoison.Error{reason: reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
27
lib/accent/auth/user_remote/authenticator.ex
Normal file
27
lib/accent/auth/user_remote/authenticator.ex
Normal file
@ -0,0 +1,27 @@
|
||||
defmodule Accent.UserRemote.Authenticator do
|
||||
alias Accent.UserRemote.{Fetcher, Persister, TokenGiver, CollaboratorNormalizer}
|
||||
|
||||
def authenticate(provider, uid) do
|
||||
provider
|
||||
|> fetch(uid)
|
||||
|> persist
|
||||
|> normalize_collaborators
|
||||
|> grant_token
|
||||
end
|
||||
|
||||
defp fetch(provider, uid), do: Fetcher.fetch(provider, uid)
|
||||
|
||||
defp persist({:error, error}), do: {:error, error}
|
||||
defp persist({:ok, user}), do: Persister.persist(user)
|
||||
|
||||
defp normalize_collaborators({:error, error}), do: {:error, error}
|
||||
|
||||
defp normalize_collaborators({:ok, user, provider}) do
|
||||
CollaboratorNormalizer.normalize(user)
|
||||
|
||||
{:ok, user, provider}
|
||||
end
|
||||
|
||||
defp grant_token({:error, error}), do: {:error, error}
|
||||
defp grant_token({:ok, user, _provider}), do: TokenGiver.grant_token(user)
|
||||
end
|
28
lib/accent/auth/user_remote/collaborator_normalizer.ex
Normal file
28
lib/accent/auth/user_remote/collaborator_normalizer.ex
Normal file
@ -0,0 +1,28 @@
|
||||
defmodule Accent.UserRemote.CollaboratorNormalizer do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Accent.{Repo, User, Collaborator}
|
||||
|
||||
@spec normalize(User.t()) :: :ok
|
||||
def normalize(%User{id: id, email: email}) do
|
||||
email
|
||||
|> fetch_collaborators()
|
||||
|> assign_user_id(id)
|
||||
|> Enum.each(&Repo.update/1)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp fetch_collaborators(email) do
|
||||
email = String.downcase(email)
|
||||
|
||||
from(c in Collaborator, where: [email: ^email])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp assign_user_id(collaborators, user_id) do
|
||||
Enum.map(collaborators, fn collaborator ->
|
||||
Collaborator.create_changeset(collaborator, %{"user_id" => user_id})
|
||||
end)
|
||||
end
|
||||
end
|
17
lib/accent/auth/user_remote/fetcher.ex
Normal file
17
lib/accent/auth/user_remote/fetcher.ex
Normal file
@ -0,0 +1,17 @@
|
||||
defmodule Accent.UserRemote.Fetcher do
|
||||
@moduledoc """
|
||||
Fetch a user (email and provider infos) based on a single value.
|
||||
"""
|
||||
|
||||
alias Accent.UserRemote.Adapters.Google
|
||||
|
||||
def fetch(_provider, ""), do: {:error, %{value: "empty"}}
|
||||
def fetch(_provider, nil), do: {:error, %{value: "empty"}}
|
||||
|
||||
if Application.get_env(:accent, :dummy_provider_enabled) do
|
||||
def fetch("dummy", value), do: Accent.UserRemote.Adapters.Dummy.fetch(value)
|
||||
end
|
||||
|
||||
def fetch("google", value), do: Google.fetch(value)
|
||||
def fetch(_provider, _value), do: {:error, %{provider: "unknown"}}
|
||||
end
|
54
lib/accent/auth/user_remote/persister.ex
Normal file
54
lib/accent/auth/user_remote/persister.ex
Normal file
@ -0,0 +1,54 @@
|
||||
defmodule Accent.UserRemote.Persister do
|
||||
@moduledoc """
|
||||
Manage user creation and provider creation.
|
||||
|
||||
This module makes sure that a user, returned from the Accent.UserRemote.Fetcher,
|
||||
is persisted in the database with its provider infos and email.
|
||||
|
||||
3 cases can happen when a user is fetched.
|
||||
|
||||
- New user with new provider. (First time logging in)
|
||||
- Existing user with same provider. (Same login as the first time)
|
||||
- Existing user but with a different provider. (Login with a different provider)
|
||||
|
||||
"""
|
||||
alias Accent.Repo
|
||||
alias Accent.AuthProvider
|
||||
alias Accent.User, as: RepoUser
|
||||
alias Accent.UserRemote.Adapter.User, as: FetchedUser
|
||||
alias Ecto.Changeset
|
||||
|
||||
@spec persist(FetchedUser.t()) :: {:ok, RepoUser.t(), AuthProvider.t()}
|
||||
def persist(user = %FetchedUser{provider: provider, uid: uid}) do
|
||||
user = find_user(user)
|
||||
provider = find_provider(user, provider, uid)
|
||||
|
||||
{:ok, user, provider}
|
||||
end
|
||||
|
||||
defp find_user(fetched_user) do
|
||||
case Repo.get_by(RepoUser, email: fetched_user.email) do
|
||||
user = %RepoUser{} -> update_user(user, fetched_user)
|
||||
_ -> create_user(fetched_user)
|
||||
end
|
||||
end
|
||||
|
||||
defp find_provider(user, provider_name, uid) do
|
||||
case Repo.get_by(AuthProvider, name: provider_name, uid: uid) do
|
||||
provider = %AuthProvider{} -> provider
|
||||
_ -> create_provider(user, provider_name, uid)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_provider(user, name, uid), do: Repo.insert!(%AuthProvider{name: name, uid: uid, user_id: user.id})
|
||||
defp create_user(fetched_user), do: Repo.insert!(%RepoUser{email: fetched_user.email, fullname: fetched_user.fullname, picture_url: fetched_user.picture_url})
|
||||
|
||||
defp update_user(user, fetched_user) do
|
||||
user
|
||||
|> Changeset.change(%{
|
||||
fullname: fetched_user.fullname || user.fullname,
|
||||
picture_url: fetched_user.picture_url || user.picture_url
|
||||
})
|
||||
|> Repo.update!()
|
||||
end
|
||||
end
|
25
lib/accent/auth/user_remote/token_giver.ex
Normal file
25
lib/accent/auth/user_remote/token_giver.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Accent.UserRemote.TokenGiver do
|
||||
alias Accent.Repo
|
||||
alias Accent.Utils.SecureRandom
|
||||
|
||||
def grant_token(user) do
|
||||
invalidate_tokens(user)
|
||||
|
||||
token = create_token(user)
|
||||
|
||||
{:ok, user, token}
|
||||
end
|
||||
|
||||
defp invalidate_tokens(user) do
|
||||
user
|
||||
|> Ecto.assoc(:access_tokens)
|
||||
|> Repo.update_all(set: [revoked_at: NaiveDateTime.utc_now()])
|
||||
end
|
||||
|
||||
defp create_token(user) do
|
||||
user
|
||||
|> Ecto.build_assoc(:access_tokens)
|
||||
|> Map.put(:token, SecureRandom.urlsafe_base64(70))
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
67
lib/accent/badge_generator.ex
Normal file
67
lib/accent/badge_generator.ex
Normal file
@ -0,0 +1,67 @@
|
||||
defmodule Accent.BadgeGenerator do
|
||||
alias Accent.{
|
||||
Revision,
|
||||
PrettyFloat,
|
||||
TranslationsCounter
|
||||
}
|
||||
|
||||
@badge_service_timeout 20_000
|
||||
@base_badge_service_url "https://img.shields.io/badge/"
|
||||
|
||||
def generate(project, attribute) do
|
||||
project_stats =
|
||||
project.revisions
|
||||
|> merge_revisions_stats()
|
||||
|> merge_project_stats()
|
||||
|
||||
color = color_for_value(project_stats[attribute], attribute)
|
||||
|
||||
(@base_badge_service_url <> "accent-#{label(project_stats[attribute], attribute)}-#{color}.svg")
|
||||
|> HTTPoison.get([], recv_timeout: @badge_service_timeout)
|
||||
|> case do
|
||||
{:ok, %{body: body}} -> {:ok, body}
|
||||
_ -> {:error, "internal error"}
|
||||
end
|
||||
end
|
||||
|
||||
defp color_for_value(value, :percentage_reviewed_count) when value < 50, do: "d84444"
|
||||
defp color_for_value(value, :percentage_reviewed_count) when value <= 75, do: "e4b600"
|
||||
defp color_for_value(_value, :percentage_reviewed_count), do: "45c86f"
|
||||
defp color_for_value(_value, _), do: "aaaaaa"
|
||||
|
||||
defp label(value, :percentage_reviewed_count), do: "#{value}%25"
|
||||
defp label(value, :translations_count), do: "#{value}%20strings"
|
||||
defp label(value, :reviewed_count), do: "#{value}%20reviewed"
|
||||
defp label(value, :conflicts_count), do: "#{value}%20conflicts"
|
||||
|
||||
defp merge_revisions_stats(revisions) do
|
||||
counts = TranslationsCounter.from_revisions(revisions)
|
||||
|
||||
revisions
|
||||
|> Enum.map(&Revision.merge_stats(&1, counts))
|
||||
end
|
||||
|
||||
defp merge_project_stats(revisions) do
|
||||
revisions
|
||||
|> Enum.reduce(%{translations_count: 0, conflicts_count: 0, reviewed_count: 0}, fn revision, acc ->
|
||||
acc
|
||||
|> Map.put(:translations_count, acc[:translations_count] + revision.translations_count)
|
||||
|> Map.put(:conflicts_count, acc[:conflicts_count] + revision.conflicts_count)
|
||||
|> Map.put(:reviewed_count, acc[:reviewed_count] + revision.reviewed_count)
|
||||
end)
|
||||
|> (fn
|
||||
stats = %{translations_count: 0} ->
|
||||
Map.put(stats, :percentage_reviewed_count, 0)
|
||||
|
||||
stats ->
|
||||
percentage_reviewed =
|
||||
stats[:reviewed_count]
|
||||
|> Kernel./(stats[:translations_count])
|
||||
|> Kernel.*(100)
|
||||
|> Float.round(2)
|
||||
|> PrettyFloat.convert()
|
||||
|
||||
Map.put(stats, :percentage_reviewed_count, percentage_reviewed)
|
||||
end).()
|
||||
end
|
||||
end
|
21
lib/accent/collaborators/collaborator_creator.ex
Normal file
21
lib/accent/collaborators/collaborator_creator.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule Accent.CollaboratorCreator do
|
||||
alias Accent.{Repo, Collaborator, User}
|
||||
|
||||
def create(params) do
|
||||
%Collaborator{}
|
||||
|> Collaborator.create_changeset(params)
|
||||
|> assign_user
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp assign_user(collaborator) do
|
||||
case fetch_user(collaborator.changes[:email]) do
|
||||
%User{id: id} -> Ecto.Changeset.put_change(collaborator, :user_id, id)
|
||||
nil -> collaborator
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_user(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
end
|
9
lib/accent/collaborators/collaborator_updater.ex
Normal file
9
lib/accent/collaborators/collaborator_updater.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule Accent.CollaboratorUpdater do
|
||||
alias Accent.{Repo, Collaborator}
|
||||
|
||||
def update(collaborator, params) do
|
||||
collaborator
|
||||
|> Collaborator.update_changeset(params)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
45
lib/accent/endpoint.ex
Normal file
45
lib/accent/endpoint.ex
Normal file
@ -0,0 +1,45 @@
|
||||
defmodule Accent.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :accent
|
||||
|
||||
socket("/socket", Accent.UserSocket)
|
||||
|
||||
if Application.get_env(:accent, :force_ssl) do
|
||||
plug(Plug.SSL, rewrite_on: [:x_forwarded_proto])
|
||||
end
|
||||
|
||||
plug(Corsica, origins: "*", allow_headers: ~w(Accept Content-Type Authorization origin))
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phoenix.digest
|
||||
# when deploying your static files in production.
|
||||
plug(Plug.Static, at: "/static", from: "priv/static", gzip: false, only: ~w(images))
|
||||
plug(Plug.Static, at: "/", from: "priv/static/webapp", gzip: false, only: ~w(assets index.html))
|
||||
|
||||
# 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)
|
||||
end
|
||||
|
||||
plug(Plug.RequestId)
|
||||
plug(Plug.Logger)
|
||||
|
||||
plug(
|
||||
Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Poison
|
||||
)
|
||||
|
||||
plug(Plug.MethodOverride)
|
||||
plug(Plug.Head)
|
||||
|
||||
if Application.get_env(:accent, :sql_sandbox) do
|
||||
plug(Phoenix.Ecto.SQL.Sandbox)
|
||||
end
|
||||
|
||||
plug(Accent.Router)
|
||||
end
|
40
lib/accent/integrations/integration_manager.ex
Normal file
40
lib/accent/integrations/integration_manager.ex
Normal file
@ -0,0 +1,40 @@
|
||||
defmodule Accent.IntegrationManager do
|
||||
alias Accent.{Repo, Integration}
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@spec create(map()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create(params) do
|
||||
%Integration{}
|
||||
|> changeset(params)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@spec update(Integration.t(), map()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update(integration, params) do
|
||||
integration
|
||||
|> changeset(params)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@spec delete(Integration.t()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete(integration) do
|
||||
integration
|
||||
|> Repo.delete()
|
||||
end
|
||||
|
||||
defp changeset(model, params) do
|
||||
model
|
||||
|> cast(params, [:project_id, :user_id, :service, :events])
|
||||
|> cast_embed(:data, with: &changeset_data/2)
|
||||
|> foreign_key_constraint(:project_id)
|
||||
|> validate_required([:service, :events, :data])
|
||||
end
|
||||
|
||||
defp changeset_data(model, params) do
|
||||
model
|
||||
|> cast(params, [:url])
|
||||
|> validate_required([:url])
|
||||
end
|
||||
end
|
3
lib/accent/mailer.ex
Normal file
3
lib/accent/mailer.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule Accent.Mailer do
|
||||
use Bamboo.Mailer, otp_app: :accent
|
||||
end
|
73
lib/accent/operations/operation_batcher.ex
Normal file
73
lib/accent/operations/operation_batcher.ex
Normal file
@ -0,0 +1,73 @@
|
||||
defmodule Accent.OperationBatcher do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Accent.{Repo, Operation}
|
||||
|
||||
@time_limit 60
|
||||
@time_unit "minute"
|
||||
|
||||
def batch(%{batch_operation_id: id}) when not is_nil(id), do: {0, nil}
|
||||
|
||||
def batch(operation) do
|
||||
with existing_operation when not is_nil(existing_operation) <- find_existing_operation(operation),
|
||||
batch_operation when not is_nil(batch_operation) <- maybe_batch(existing_operation) do
|
||||
from(
|
||||
o in Operation,
|
||||
where: o.id in ^[existing_operation.id, operation.id]
|
||||
)
|
||||
|> Repo.update_all(set: [batch_operation_id: batch_operation.id])
|
||||
else
|
||||
_ -> {0, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_existing_operation(%{action: action} = operation) when action in ["correct_conflict", "update"] do
|
||||
case do_find_existing_operation(operation) do
|
||||
nil -> nil
|
||||
operation -> Repo.preload(operation, :batch_operation)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_find_existing_operation(%{action: action, id: id, inserted_at: inserted_at, user_id: user_id, revision_id: revision_id}) do
|
||||
from(
|
||||
operation in Operation,
|
||||
where: operation.id != ^id,
|
||||
where: [revision_id: ^revision_id],
|
||||
where: [user_id: ^user_id],
|
||||
where: [action: ^action],
|
||||
where: [rollbacked: false],
|
||||
where: operation.inserted_at >= datetime_add(^inserted_at, ^(-@time_limit), ^@time_unit),
|
||||
order_by: [asc: :inserted_at],
|
||||
limit: 1
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp maybe_batch(nil), do: nil
|
||||
defp maybe_batch(operation = %{batch_operation_id: nil}), do: create_batch_operation(operation)
|
||||
defp maybe_batch(%{batch_operation: batch_operation}), do: update_batch_operation(batch_operation)
|
||||
|
||||
defp update_batch_operation(batch_operation) do
|
||||
batch_operation
|
||||
|> Operation.stats_changeset(%{stats: increment_stats_count(batch_operation)})
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
defp create_batch_operation(%{action: action, user_id: user_id, revision_id: revision_id}) do
|
||||
%Operation{
|
||||
batch: true,
|
||||
action: batch_operation_action(action),
|
||||
revision_id: revision_id,
|
||||
user_id: user_id,
|
||||
stats: batch_operation_stats(action)
|
||||
}
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
defp increment_stats_count(batch_operation) do
|
||||
Enum.map(batch_operation.stats, fn stat -> update_in(stat, ["count"], fn count -> count + 1 end) end)
|
||||
end
|
||||
|
||||
defp batch_operation_action(action), do: "batch_" <> action
|
||||
defp batch_operation_stats(action), do: [%{"count" => 2, "action" => action}]
|
||||
end
|
53
lib/accent/projects/project_creator.ex
Normal file
53
lib/accent/projects/project_creator.ex
Normal file
@ -0,0 +1,53 @@
|
||||
defmodule Accent.ProjectCreator do
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Accent.{Project, Repo, User, UserRemote.TokenGiver}
|
||||
alias Ecto.Changeset
|
||||
|
||||
@required_fields ~w(name language_id)a
|
||||
@bot %User{fullname: "API Client", bot: true}
|
||||
|
||||
def create(params: params, user: user) do
|
||||
changeset =
|
||||
with changeset = %Changeset{valid?: true} <- cast_changeset(%Project{}, params),
|
||||
changeset = %Changeset{valid?: true} <- build_master_revision(changeset),
|
||||
changeset = %Changeset{valid?: true} <- build_collaborations(changeset, user),
|
||||
do: changeset
|
||||
|
||||
Repo.insert(changeset)
|
||||
end
|
||||
|
||||
def cast_changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields ++ [])
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
|
||||
def build_master_revision(changeset) do
|
||||
revision =
|
||||
Ecto.build_assoc(changeset.data, :revisions, %{
|
||||
language_id: changeset.params["language_id"],
|
||||
master: true
|
||||
})
|
||||
|
||||
put_assoc(changeset, :revisions, [revision])
|
||||
end
|
||||
|
||||
def build_collaborations(changeset, user) do
|
||||
bot_user = generate_bot_user_with_access()
|
||||
|
||||
bot = Ecto.build_assoc(changeset.data, :collaborators, %{role: "bot", email: bot_user.email, user_id: bot_user.id})
|
||||
owner = Ecto.build_assoc(changeset.data, :collaborators, %{role: "owner", email: user.email, user_id: user.id})
|
||||
|
||||
put_assoc(changeset, :collaborators, [owner, bot])
|
||||
end
|
||||
|
||||
def generate_bot_user_with_access do
|
||||
{:ok, bot_user, _token} =
|
||||
@bot
|
||||
|> Repo.insert!()
|
||||
|> TokenGiver.grant_token()
|
||||
|
||||
bot_user
|
||||
end
|
||||
end
|
11
lib/accent/projects/project_deleter.ex
Normal file
11
lib/accent/projects/project_deleter.ex
Normal file
@ -0,0 +1,11 @@
|
||||
defmodule Accent.ProjectDeleter do
|
||||
alias Accent.Repo
|
||||
|
||||
def delete(project: project) do
|
||||
project
|
||||
|> Ecto.assoc(:collaborators)
|
||||
|> Repo.delete_all()
|
||||
|
||||
{:ok, project}
|
||||
end
|
||||
end
|
25
lib/accent/projects/project_updater.ex
Normal file
25
lib/accent/projects/project_updater.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Accent.ProjectUpdater do
|
||||
alias Accent.Repo
|
||||
|
||||
import Canada, only: [can?: 2]
|
||||
|
||||
@optional_fields ~w(name)
|
||||
|
||||
def update(project: project, params: params, user: user) do
|
||||
project
|
||||
|> cast_changeset(params, user)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def cast_changeset(model, params, user) do
|
||||
fields =
|
||||
if user |> can?(locked_file_operations(model)) do
|
||||
@optional_fields ++ ["locked_file_operations"]
|
||||
else
|
||||
@optional_fields
|
||||
end
|
||||
|
||||
model
|
||||
|> Ecto.Changeset.cast(params, fields)
|
||||
end
|
||||
end
|
4
lib/accent/repo.ex
Normal file
4
lib/accent/repo.ex
Normal file
@ -0,0 +1,4 @@
|
||||
defmodule Accent.Repo do
|
||||
use Ecto.Repo, otp_app: :accent
|
||||
use Scrivener, page_size: 30, max_page_size: 50
|
||||
end
|
17
lib/accent/revisions/revision_deleter.ex
Normal file
17
lib/accent/revisions/revision_deleter.ex
Normal file
@ -0,0 +1,17 @@
|
||||
defmodule Accent.RevisionDeleter do
|
||||
alias Ecto.Multi
|
||||
alias Accent.Repo
|
||||
|
||||
def delete(revision: %{master: true}), do: {:error, "can't delete master language"}
|
||||
|
||||
def delete(revision: revision) do
|
||||
translations = Ecto.assoc(revision, :translations)
|
||||
operations = Ecto.assoc(revision, :operations)
|
||||
|
||||
Multi.new()
|
||||
|> Multi.delete_all(:operations, operations)
|
||||
|> Multi.delete_all(:translations, translations)
|
||||
|> Multi.delete(:revision, revision)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
end
|
30
lib/accent/revisions/revision_master_promoter.ex
Normal file
30
lib/accent/revisions/revision_master_promoter.ex
Normal file
@ -0,0 +1,30 @@
|
||||
defmodule Accent.RevisionMasterPromoter do
|
||||
alias Accent.{Repo, Revision}
|
||||
|
||||
require Ecto.Query
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
def promote(revision: revision = %{master: true}) do
|
||||
revision
|
||||
|> change()
|
||||
|> add_error(:master, "invalid")
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def promote(revision: revision) do
|
||||
revision
|
||||
|> change()
|
||||
|> put_change(:master, true)
|
||||
|> put_change(:master_revision_id, nil)
|
||||
|> prepare_changes(fn changeset ->
|
||||
Revision
|
||||
|> Ecto.Query.where([r], r.id != ^changeset.data.id)
|
||||
|> Ecto.Query.where([r], r.project_id == ^changeset.data.project_id)
|
||||
|> changeset.repo.update_all(set: [master_revision_id: changeset.data.id, master: false])
|
||||
|
||||
changeset
|
||||
end)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
12
lib/accent/schemas/access_token.ex
Normal file
12
lib/accent/schemas/access_token.ex
Normal file
@ -0,0 +1,12 @@
|
||||
defmodule Accent.AccessToken do
|
||||
use Accent.Schema
|
||||
|
||||
schema "auth_access_tokens" do
|
||||
field(:token, :string)
|
||||
field(:revoked_at, :naive_datetime)
|
||||
|
||||
belongs_to(:user, Accent.User)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
12
lib/accent/schemas/auth_provider.ex
Normal file
12
lib/accent/schemas/auth_provider.ex
Normal file
@ -0,0 +1,12 @@
|
||||
defmodule Accent.AuthProvider do
|
||||
use Accent.Schema
|
||||
|
||||
schema "auth_providers" do
|
||||
field(:name, :string)
|
||||
field(:uid, :string)
|
||||
|
||||
belongs_to(:user, Accent.User)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
41
lib/accent/schemas/collaborator.ex
Normal file
41
lib/accent/schemas/collaborator.ex
Normal file
@ -0,0 +1,41 @@
|
||||
defmodule Accent.Collaborator do
|
||||
use Accent.Schema
|
||||
|
||||
require Accent.Role
|
||||
|
||||
schema "collaborators" do
|
||||
field(:email, :string)
|
||||
field(:role, :string)
|
||||
|
||||
belongs_to(:user, Accent.User)
|
||||
belongs_to(:assigner, Accent.User)
|
||||
belongs_to(:project, Accent.Project)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields ~w(email assigner_id role project_id)a
|
||||
@optional_fields ~w(user_id)a
|
||||
@possible_roles Accent.Role.slugs()
|
||||
|
||||
def create_changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_format(:email, ~r/.+@.+/)
|
||||
|> downcase_email(params)
|
||||
|> validate_inclusion(:role, @possible_roles)
|
||||
end
|
||||
|
||||
def update_changeset(model, params) do
|
||||
model
|
||||
|> cast(params, [:role])
|
||||
|> validate_inclusion(:role, @possible_roles)
|
||||
end
|
||||
|
||||
defp downcase_email(changeset, %{"email" => email}) do
|
||||
put_change(changeset, :email, String.downcase(email))
|
||||
end
|
||||
|
||||
defp downcase_email(changeset, _params), do: changeset
|
||||
end
|
30
lib/accent/schemas/comment.ex
Normal file
30
lib/accent/schemas/comment.ex
Normal file
@ -0,0 +1,30 @@
|
||||
defmodule Accent.Comment do
|
||||
use Accent.Schema
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
schema "comments" do
|
||||
field(:text, :string)
|
||||
|
||||
belongs_to(:translation, Accent.Translation)
|
||||
belongs_to(:user, Accent.User)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields ~w(text user_id translation_id)a
|
||||
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields ++ [])
|
||||
|> validate_required(@required_fields)
|
||||
|> assoc_constraint(:user)
|
||||
|> assoc_constraint(:translation)
|
||||
|> prepare_changes(fn changeset ->
|
||||
from(t in Accent.Translation, where: t.id == ^changeset.changes[:translation_id])
|
||||
|> changeset.repo.update_all(inc: [comments_count: 1])
|
||||
|
||||
changeset
|
||||
end)
|
||||
end
|
||||
end
|
41
lib/accent/schemas/document.ex
Normal file
41
lib/accent/schemas/document.ex
Normal file
@ -0,0 +1,41 @@
|
||||
defmodule Accent.Document do
|
||||
use Accent.Schema
|
||||
|
||||
require Accent.DocumentFormat
|
||||
|
||||
schema "documents" do
|
||||
field(:path, :string)
|
||||
|
||||
field(:format, :string)
|
||||
field(:render, :string, virtual: true)
|
||||
|
||||
field(:top_of_the_file_comment, :string, default: "")
|
||||
field(:header, :string, default: "")
|
||||
|
||||
belongs_to(:project, Accent.Project)
|
||||
|
||||
field(:translations_count, :integer, virtual: true, default: :not_loaded)
|
||||
field(:reviewed_count, :integer, virtual: true, default: :not_loaded)
|
||||
field(:conflicts_count, :integer, virtual: true, default: :not_loaded)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@possible_formats Accent.DocumentFormat.slugs()
|
||||
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, [:format, :project_id, :path, :top_of_the_file_comment, :header])
|
||||
|> validate_required([:format, :path, :project_id])
|
||||
|> validate_inclusion(:format, @possible_formats)
|
||||
|> unique_constraint(:path, name: :documents_path_format_project_id_index)
|
||||
end
|
||||
|
||||
def merge_stats(document, stats) do
|
||||
translations_count = stats[document.id][:active] || 0
|
||||
conflicts_count = stats[document.id][:conflicted] || 0
|
||||
reviewed_count = translations_count - conflicts_count
|
||||
|
||||
%{document | translations_count: translations_count, conflicts_count: conflicts_count, reviewed_count: reviewed_count}
|
||||
end
|
||||
end
|
45
lib/accent/schemas/document_format.ex
Normal file
45
lib/accent/schemas/document_format.ex
Normal file
@ -0,0 +1,45 @@
|
||||
defmodule Accent.DocumentFormat do
|
||||
@enforce_keys ~w(name slug extension)a
|
||||
defstruct name: nil, slug: nil, extension: nil
|
||||
|
||||
@type t :: struct
|
||||
|
||||
@all [
|
||||
%{name: "Simple JSON", slug: "simple_json", extension: "json"},
|
||||
%{name: "JSON", slug: "json", extension: "json"},
|
||||
%{name: "Apple .strings", slug: "strings", extension: "strings"},
|
||||
%{name: "Gettext", slug: "gettext", extension: "po"},
|
||||
%{name: "Rails YAML", slug: "rails_yml", extension: "yml"},
|
||||
%{name: "ES6 module", slug: "es6_module", extension: "js"},
|
||||
%{name: "Android XML", slug: "android_xml", extension: "xml"},
|
||||
%{name: "Java properties", slug: "java_properties", extension: "properties"},
|
||||
%{name: "Java properties XML", slug: "java_properties_xml", extension: "xml"}
|
||||
]
|
||||
|
||||
@doc """
|
||||
Slugs used in document changeset validation
|
||||
|
||||
## Examples
|
||||
iex> Accent.DocumentFormat.slugs()
|
||||
["simple_json", "json", "strings", "gettext", "rails_yml", "es6_module", "android_xml", "java_properties", "java_properties_xml"]
|
||||
"""
|
||||
defmacro slugs, do: Enum.map(@all, &Map.get(&1, :slug))
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.DocumentFormat.all()
|
||||
[
|
||||
%Accent.DocumentFormat{extension: "json", name: "Simple JSON", slug: "simple_json"},
|
||||
%Accent.DocumentFormat{extension: "json", name: "JSON", slug: "json"},
|
||||
%Accent.DocumentFormat{extension: "strings", name: "Apple .strings", slug: "strings"},
|
||||
%Accent.DocumentFormat{extension: "po", name: "Gettext", slug: "gettext"},
|
||||
%Accent.DocumentFormat{extension: "yml", name: "Rails YAML", slug: "rails_yml"},
|
||||
%Accent.DocumentFormat{extension: "js", name: "ES6 module", slug: "es6_module"},
|
||||
%Accent.DocumentFormat{extension: "xml", name: "Android XML", slug: "android_xml"},
|
||||
%Accent.DocumentFormat{extension: "properties", name: "Java properties", slug: "java_properties"},
|
||||
%Accent.DocumentFormat{extension: "xml", name: "Java properties XML", slug: "java_properties_xml"}
|
||||
]
|
||||
"""
|
||||
def all, do: Enum.map(@all, &struct(__MODULE__, &1))
|
||||
end
|
14
lib/accent/schemas/integration.ex
Normal file
14
lib/accent/schemas/integration.ex
Normal file
@ -0,0 +1,14 @@
|
||||
defmodule Accent.Integration do
|
||||
use Accent.Schema
|
||||
|
||||
schema "integrations" do
|
||||
field(:service, :string)
|
||||
field(:events, {:array, :string})
|
||||
|
||||
embeds_one(:data, Accent.IntegrationData, on_replace: :update)
|
||||
belongs_to(:project, Accent.Project)
|
||||
belongs_to(:user, Accent.User)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
7
lib/accent/schemas/integration_data.ex
Normal file
7
lib/accent/schemas/integration_data.ex
Normal file
@ -0,0 +1,7 @@
|
||||
defmodule Accent.IntegrationData do
|
||||
use Accent.Schema
|
||||
|
||||
schema "" do
|
||||
field(:url)
|
||||
end
|
||||
end
|
17
lib/accent/schemas/language.ex
Normal file
17
lib/accent/schemas/language.ex
Normal file
@ -0,0 +1,17 @@
|
||||
defmodule Accent.Language do
|
||||
use Accent.Schema
|
||||
|
||||
schema "languages" do
|
||||
field(:name, :string)
|
||||
field(:slug, :string)
|
||||
|
||||
field(:iso_639_1, :string)
|
||||
field(:iso_639_3, :string)
|
||||
field(:locale, :string)
|
||||
field(:android_code, :string)
|
||||
field(:osx_code, :string)
|
||||
field(:osx_locale, :string)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
79
lib/accent/schemas/operation.ex
Normal file
79
lib/accent/schemas/operation.ex
Normal file
@ -0,0 +1,79 @@
|
||||
defmodule Accent.Operation do
|
||||
use Accent.Schema
|
||||
|
||||
@duplicated_fields [
|
||||
:action,
|
||||
:key,
|
||||
:text,
|
||||
:conflicted,
|
||||
:value_type,
|
||||
:file_index,
|
||||
:file_comment,
|
||||
:removed,
|
||||
:revision_id,
|
||||
:translation_id,
|
||||
:user_id,
|
||||
:batch_operation_id,
|
||||
:document_id,
|
||||
:version_id,
|
||||
:project_id,
|
||||
:stats,
|
||||
:previous_translation
|
||||
]
|
||||
|
||||
schema "operations" do
|
||||
field(:action, :string)
|
||||
field(:key, :string)
|
||||
field(:text, :string)
|
||||
field(:batch, :boolean, default: false)
|
||||
|
||||
field(:file_comment, :string)
|
||||
field(:file_index, :integer)
|
||||
|
||||
field(:value_type, :string)
|
||||
|
||||
field(:previous_translation, :map)
|
||||
field(:rollbacked, :boolean, default: false)
|
||||
field(:stats, {:array, :map}, default: [])
|
||||
|
||||
belongs_to(:document, Accent.Document)
|
||||
belongs_to(:revision, Accent.Revision)
|
||||
belongs_to(:version, Accent.Version)
|
||||
belongs_to(:translation, Accent.Translation)
|
||||
belongs_to(:project, Accent.Project)
|
||||
belongs_to(:comment, Accent.Comment)
|
||||
belongs_to(:user, Accent.User)
|
||||
belongs_to(:batch_operation, Accent.Operation)
|
||||
belongs_to(:rollbacked_operation, Accent.Operation)
|
||||
|
||||
has_one(:rollback_operation, Accent.Operation, foreign_key: :rollbacked_operation_id)
|
||||
has_many(:operations, Accent.Operation, foreign_key: :batch_operation_id)
|
||||
|
||||
field(:language_id, :string, virtual: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@optional_fields [
|
||||
:rollbacked,
|
||||
:translation_id,
|
||||
:comment_id
|
||||
]
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, [] ++ @optional_fields)
|
||||
end
|
||||
|
||||
def stats_changeset(model, params) do
|
||||
model
|
||||
|> cast(params, [:stats])
|
||||
end
|
||||
|
||||
def copy(operation, new_fields) do
|
||||
duplicated_operation = Map.take(operation, @duplicated_fields)
|
||||
|
||||
%__MODULE__{}
|
||||
|> Map.merge(duplicated_operation)
|
||||
|> Map.merge(new_fields)
|
||||
end
|
||||
end
|
38
lib/accent/schemas/previous_translation.ex
Normal file
38
lib/accent/schemas/previous_translation.ex
Normal file
@ -0,0 +1,38 @@
|
||||
defmodule Accent.PreviousTranslation do
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.PreviousTranslation.from_translation(nil)
|
||||
%{}
|
||||
iex> Accent.PreviousTranslation.from_translation(%{})
|
||||
%{}
|
||||
iex> Accent.PreviousTranslation.from_translation(%Accent.Translation{proposed_text: "a", corrected_text: "b", conflicted_text: "c", conflicted: true, removed: false, value_type: "text"})
|
||||
%{"proposed_text" => "a", "corrected_text" => "b", "conflicted_text" => "c", "conflicted" => true, "removed" => false, "value_type" => "text"}
|
||||
iex> Accent.PreviousTranslation.to_translation(%{"proposed_text" => "a", "corrected_text" => "b", "conflicted_text" => "c", "conflicted" => true, "removed" => false, "value_type" => "text"})
|
||||
%{proposed_text: "a", corrected_text: "b", conflicted_text: "c", conflicted: true, removed: false, value_type: "text"}
|
||||
"""
|
||||
def from_translation(nil), do: %{}
|
||||
def from_translation(translation) when map_size(translation) == 0, do: %{}
|
||||
|
||||
def from_translation(translation) do
|
||||
%{
|
||||
"proposed_text" => translation.proposed_text,
|
||||
"corrected_text" => translation.corrected_text,
|
||||
"conflicted_text" => translation.conflicted_text,
|
||||
"conflicted" => translation.conflicted,
|
||||
"removed" => translation.removed,
|
||||
"value_type" => translation.value_type
|
||||
}
|
||||
end
|
||||
|
||||
def to_translation(translation) do
|
||||
%{
|
||||
proposed_text: translation["proposed_text"],
|
||||
corrected_text: translation["corrected_text"],
|
||||
conflicted_text: translation["conflicted_text"],
|
||||
conflicted: translation["conflicted"],
|
||||
removed: translation["removed"],
|
||||
value_type: translation["value_type"]
|
||||
}
|
||||
end
|
||||
end
|
29
lib/accent/schemas/project.ex
Normal file
29
lib/accent/schemas/project.ex
Normal file
@ -0,0 +1,29 @@
|
||||
defmodule Accent.Project do
|
||||
use Accent.Schema
|
||||
|
||||
schema "projects" do
|
||||
field(:name, :string)
|
||||
field(:last_synced_at, :naive_datetime)
|
||||
field(:locked_file_operations, :boolean, default: false)
|
||||
|
||||
has_many(:integrations, Accent.Integration)
|
||||
has_many(:revisions, Accent.Revision)
|
||||
has_many(:versions, Accent.Version)
|
||||
has_many(:operations, Accent.Operation)
|
||||
has_many(:collaborators, Accent.Collaborator)
|
||||
belongs_to(:language, Accent.Language)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@optional_fields [
|
||||
:name,
|
||||
:last_synced_at,
|
||||
:locked_file_operations
|
||||
]
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @optional_fields)
|
||||
|> validate_required([:name])
|
||||
end
|
||||
end
|
39
lib/accent/schemas/revision.ex
Normal file
39
lib/accent/schemas/revision.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule Accent.Revision do
|
||||
use Accent.Schema
|
||||
|
||||
schema "revisions" do
|
||||
field(:master, :boolean, default: true)
|
||||
|
||||
belongs_to(:master_revision, Accent.Revision)
|
||||
belongs_to(:project, Accent.Project)
|
||||
belongs_to(:language, Accent.Language)
|
||||
|
||||
has_many(:translations, Accent.Translation)
|
||||
has_many(:operations, Accent.Operation)
|
||||
|
||||
field(:translations_count, :integer, virtual: true, default: :not_loaded)
|
||||
field(:reviewed_count, :integer, virtual: true, default: :not_loaded)
|
||||
field(:conflicts_count, :integer, virtual: true, default: :not_loaded)
|
||||
|
||||
field(:translation_ids, {:array, :string}, virtual: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields [:language_id, :project_id, :master_revision_id, :master]
|
||||
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields ++ [])
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint(:language, name: :revisions_project_id_language_id_index)
|
||||
end
|
||||
|
||||
def merge_stats(revision, stats) do
|
||||
translations_count = stats[revision.id][:active] || 0
|
||||
conflicts_count = stats[revision.id][:conflicted] || 0
|
||||
reviewed_count = translations_count - conflicts_count
|
||||
|
||||
%{revision | translations_count: translations_count, conflicts_count: conflicts_count, reviewed_count: reviewed_count}
|
||||
end
|
||||
end
|
33
lib/accent/schemas/role.ex
Normal file
33
lib/accent/schemas/role.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule Accent.Role do
|
||||
defstruct slug: nil
|
||||
|
||||
@type t :: struct
|
||||
|
||||
@all [
|
||||
%{slug: "owner"},
|
||||
%{slug: "admin"},
|
||||
%{slug: "developer"},
|
||||
%{slug: "reviewer"}
|
||||
]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Role.slugs()
|
||||
["owner", "admin", "developer", "reviewer"]
|
||||
"""
|
||||
defmacro slugs, do: Enum.map(@all, &Map.get(&1, :slug))
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Role.all()
|
||||
[
|
||||
%Accent.Role{slug: "owner"},
|
||||
%Accent.Role{slug: "admin"},
|
||||
%Accent.Role{slug: "developer"},
|
||||
%Accent.Role{slug: "reviewer"}
|
||||
]
|
||||
"""
|
||||
def all, do: Enum.map(@all, &struct(__MODULE__, &1))
|
||||
end
|
14
lib/accent/schemas/schema.ex
Normal file
14
lib/accent/schemas/schema.ex
Normal file
@ -0,0 +1,14 @@
|
||||
defmodule Accent.Schema do
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
use Ecto.Schema
|
||||
|
||||
@type t :: struct
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
import Ecto
|
||||
import Ecto.Changeset
|
||||
end
|
||||
end
|
||||
end
|
49
lib/accent/schemas/translation.ex
Normal file
49
lib/accent/schemas/translation.ex
Normal file
@ -0,0 +1,49 @@
|
||||
defmodule Accent.Translation do
|
||||
use Accent.Schema
|
||||
|
||||
schema "translations" do
|
||||
field(:key, :string)
|
||||
field(:proposed_text, :string, default: "")
|
||||
field(:corrected_text, :string, default: "")
|
||||
field(:conflicted_text, :string, default: "")
|
||||
field(:conflicted, :boolean, default: false)
|
||||
field(:removed, :boolean, default: false)
|
||||
field(:comments_count, :integer, default: 0)
|
||||
|
||||
field(:file_comment, :string)
|
||||
field(:file_index, :integer)
|
||||
|
||||
field(:value_type, :string)
|
||||
|
||||
belongs_to(:document, Accent.Document)
|
||||
belongs_to(:revision, Accent.Revision)
|
||||
has_one(:project, through: [:revision, :project])
|
||||
belongs_to(:version, Accent.Version)
|
||||
belongs_to(:source_translation, __MODULE__)
|
||||
has_many(:operations, Accent.Operation)
|
||||
has_many(:comments, Accent.Comment)
|
||||
has_many(:comments_subscriptions, Accent.TranslationCommentsSubscription)
|
||||
|
||||
field(:marked_as_removed, :string, virtual: true)
|
||||
field(:text, :string, virtual: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@optional_fields [
|
||||
:proposed_text,
|
||||
:corrected_text,
|
||||
:conflicted_text,
|
||||
:conflicted,
|
||||
:removed,
|
||||
:comments_count,
|
||||
:file_index,
|
||||
:file_comment,
|
||||
:value_type,
|
||||
:document_id
|
||||
]
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @optional_fields)
|
||||
end
|
||||
end
|
21
lib/accent/schemas/translation_comments_subscription.ex
Normal file
21
lib/accent/schemas/translation_comments_subscription.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule Accent.TranslationCommentsSubscription do
|
||||
use Accent.Schema
|
||||
|
||||
schema "translation_comments_subscriptions" do
|
||||
belongs_to(:user, Accent.User)
|
||||
belongs_to(:translation, Accent.Translation)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields [
|
||||
:user_id,
|
||||
:translation_id
|
||||
]
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint(:user, name: :translation_comments_subscriptions_user_id_translation_id_index)
|
||||
end
|
||||
end
|
30
lib/accent/schemas/user.ex
Normal file
30
lib/accent/schemas/user.ex
Normal file
@ -0,0 +1,30 @@
|
||||
defmodule Accent.User do
|
||||
use Accent.Schema
|
||||
|
||||
schema "users" do
|
||||
field(:email, :string)
|
||||
field(:fullname, :string)
|
||||
field(:picture_url, :string)
|
||||
field(:bot, :boolean, default: false)
|
||||
|
||||
has_many(:access_tokens, Accent.AccessToken)
|
||||
has_many(:auth_providers, Accent.AuthProvider)
|
||||
has_many(:collaborations, Accent.Collaborator)
|
||||
has_many(:collaboration_assigns, Accent.Collaborator, foreign_key: :assigner_id)
|
||||
|
||||
field(:permissions, :map, virtual: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.User.name_with_fallback(%{fullname: "test", email: "foo@bar.com"})
|
||||
"test"
|
||||
iex> Accent.User.name_with_fallback(%{fullname: nil, email: "foo@bar.com"})
|
||||
"foo@bar.com"
|
||||
"""
|
||||
def name_with_fallback(%{fullname: fullname, email: email}) when is_nil(fullname), do: email
|
||||
def name_with_fallback(%{fullname: fullname}), do: fullname
|
||||
end
|
25
lib/accent/schemas/version.ex
Normal file
25
lib/accent/schemas/version.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Accent.Version do
|
||||
use Accent.Schema
|
||||
|
||||
schema "versions" do
|
||||
field(:name, :string)
|
||||
field(:tag, :string)
|
||||
|
||||
belongs_to(:user, Accent.User)
|
||||
belongs_to(:project, Accent.Project)
|
||||
|
||||
has_many(:translations, Accent.Translation)
|
||||
has_many(:operations, Accent.Operation)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields [:project_id, :user_id, :name, :tag]
|
||||
|
||||
def changeset(model, params) do
|
||||
model
|
||||
|> cast(params, @required_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint(:tag, name: :versions_tag_project_id_index)
|
||||
end
|
||||
end
|
33
lib/accent/scopes/comment.ex
Normal file
33
lib/accent/scopes/comment.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule Accent.Scopes.Comment do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Comment.default_order(Accent.Comment)
|
||||
#Ecto.Query<from c in Accent.Comment, order_by: [desc: c.inserted_at]>
|
||||
"""
|
||||
@spec default_order(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def default_order(query) do
|
||||
from(c in query, order_by: [desc: :inserted_at])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Comment.from_project(Accent.Comment, "test")
|
||||
#Ecto.Query<from c in Accent.Comment, join: t in assoc(c, :translation), join: r in assoc(t, :revision), join: p in assoc(r, :project), where: p.id == ^\"test\", order_by: [desc: c.inserted_at], select: c>
|
||||
"""
|
||||
@spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_project(query, project_id) do
|
||||
from(
|
||||
comment in query,
|
||||
inner_join: translation in assoc(comment, :translation),
|
||||
inner_join: revision in assoc(translation, :revision),
|
||||
inner_join: project in assoc(revision, :project),
|
||||
where: project.id == ^project_id,
|
||||
order_by: [desc: comment.inserted_at],
|
||||
select: comment
|
||||
)
|
||||
end
|
||||
end
|
25
lib/accent/scopes/document.ex
Normal file
25
lib/accent/scopes/document.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Accent.Scopes.Document do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Document.from_project(Accent.Document, "test")
|
||||
#Ecto.Query<from d in Accent.Document, where: d.project_id == ^\"test\">
|
||||
"""
|
||||
@spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_project(query, project_id) do
|
||||
from(d in query, where: [project_id: ^project_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Document.from_path(Accent.Document, "test")
|
||||
#Ecto.Query<from d in Accent.Document, where: d.path == ^\"test\">
|
||||
"""
|
||||
@spec from_path(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_path(query, path) do
|
||||
from(d in query, where: [path: ^path])
|
||||
end
|
||||
end
|
26
lib/accent/scopes/language.ex
Normal file
26
lib/accent/scopes/language.ex
Normal file
@ -0,0 +1,26 @@
|
||||
defmodule Accent.Scopes.Language do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Language.from_search(Accent.Language, "")
|
||||
Accent.Language
|
||||
iex> Accent.Scopes.Language.from_search(Accent.Language, nil)
|
||||
Accent.Language
|
||||
iex> Accent.Scopes.Language.from_search(Accent.Language, 1234)
|
||||
Accent.Language
|
||||
iex> Accent.Scopes.Language.from_search(Accent.Language, "test")
|
||||
#Ecto.Query<from l in Accent.Language, where: ilike(l.name, ^"%test%")>
|
||||
"""
|
||||
@spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_search(query, nil), do: query
|
||||
def from_search(query, term) when term === "", do: query
|
||||
def from_search(query, term) when not is_binary(term), do: query
|
||||
|
||||
def from_search(query, term) do
|
||||
term = "%" <> term <> "%"
|
||||
|
||||
from(l in query, where: ilike(l.name, ^term))
|
||||
end
|
||||
end
|
79
lib/accent/scopes/operation.ex
Normal file
79
lib/accent/scopes/operation.ex
Normal file
@ -0,0 +1,79 @@
|
||||
defmodule Accent.Scopes.Operation do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Operation.filter_from_user(Accent.Operation, nil)
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.filter_from_user(Accent.Operation, "test")
|
||||
#Ecto.Query<from o in Accent.Operation, where: o.user_id == ^"test">
|
||||
"""
|
||||
@spec filter_from_user(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def filter_from_user(query, nil), do: query
|
||||
|
||||
def filter_from_user(query, user_id) do
|
||||
from(o in query, where: [user_id: ^user_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Operation.filter_from_action(Accent.Operation, nil)
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.filter_from_action(Accent.Operation, "test")
|
||||
#Ecto.Query<from o in Accent.Operation, where: o.action == ^"test">
|
||||
"""
|
||||
@spec filter_from_action(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def filter_from_action(query, nil), do: query
|
||||
|
||||
def filter_from_action(query, action) do
|
||||
from(o in query, where: [action: ^action])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, nil)
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, "test")
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.filter_from_batch(Accent.Operation, true)
|
||||
#Ecto.Query<from o in Accent.Operation, where: o.batch == ^true>
|
||||
"""
|
||||
@spec filter_from_batch(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def filter_from_batch(query, nil), do: query
|
||||
def filter_from_batch(query, batch) when not is_boolean(batch), do: query
|
||||
|
||||
def filter_from_batch(query, batch) do
|
||||
from(o in query, where: [batch: ^batch])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Operation.order_last_to_first(Accent.Operation)
|
||||
#Ecto.Query<from o in Accent.Operation, order_by: [desc: o.inserted_at, asc: o.batch]>
|
||||
"""
|
||||
@spec order_last_to_first(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def order_last_to_first(query) do
|
||||
from(o in query, order_by: [desc: :inserted_at, asc: :batch])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, "action", nil)
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, nil, true)
|
||||
Accent.Operation
|
||||
iex> Accent.Scopes.Operation.ignore_actions(Accent.Operation, nil, nil)
|
||||
#Ecto.Query<from o in Accent.Operation, where: is_nil(o.batch_operation_id)>
|
||||
"""
|
||||
@spec ignore_actions(Ecto.Queryable.t(), any(), any()) :: Ecto.Queryable.t()
|
||||
def ignore_actions(query, nil, nil) do
|
||||
from(o in query, where: is_nil(o.batch_operation_id))
|
||||
end
|
||||
|
||||
def ignore_actions(query, _action_argument, _batch_argument), do: query
|
||||
end
|
26
lib/accent/scopes/project.ex
Normal file
26
lib/accent/scopes/project.ex
Normal file
@ -0,0 +1,26 @@
|
||||
defmodule Accent.Scopes.Project do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Project.from_search(Accent.Project, "")
|
||||
Accent.Project
|
||||
iex> Accent.Scopes.Project.from_search(Accent.Project, nil)
|
||||
Accent.Project
|
||||
iex> Accent.Scopes.Project.from_search(Accent.Project, 1234)
|
||||
Accent.Project
|
||||
iex> Accent.Scopes.Project.from_search(Accent.Project, "test")
|
||||
#Ecto.Query<from p in Accent.Project, where: ilike(p.name, ^"%test%")>
|
||||
"""
|
||||
@spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_search(query, nil), do: query
|
||||
def from_search(query, term) when term === "", do: query
|
||||
def from_search(query, term) when not is_binary(term), do: query
|
||||
|
||||
def from_search(query, term) do
|
||||
term = "%" <> term <> "%"
|
||||
|
||||
from(p in query, where: ilike(p.name, ^term))
|
||||
end
|
||||
end
|
52
lib/accent/scopes/revision.ex
Normal file
52
lib/accent/scopes/revision.ex
Normal file
@ -0,0 +1,52 @@
|
||||
defmodule Accent.Scopes.Revision do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Revision.from_project(Accent.Revision, "test")
|
||||
#Ecto.Query<from r in Accent.Revision, join: l in assoc(r, :language), where: r.project_id == ^\"test\", order_by: [desc: r.master, asc: l.name]>
|
||||
"""
|
||||
@spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_project(query, project_id) do
|
||||
from(
|
||||
revision in query,
|
||||
where: [project_id: ^project_id],
|
||||
inner_join: language in assoc(revision, :language),
|
||||
order_by: [desc: revision.master, asc: language.name]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Revision.from_language(Accent.Revision, "test")
|
||||
#Ecto.Query<from r in Accent.Revision, where: r.language_id == ^\"test\">
|
||||
"""
|
||||
@spec from_language(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_language(query, language_id) do
|
||||
from(r in query, where: [language_id: ^language_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Revision.master(Accent.Revision)
|
||||
#Ecto.Query<from r in Accent.Revision, where: r.master == true>
|
||||
"""
|
||||
@spec master(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def master(query) do
|
||||
from(r in query, where: [master: true])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Revision.slaves(Accent.Revision)
|
||||
#Ecto.Query<from r in Accent.Revision, where: r.master == false>
|
||||
"""
|
||||
@spec slaves(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def slaves(query) do
|
||||
from(r in query, where: [master: false])
|
||||
end
|
||||
end
|
236
lib/accent/scopes/translation.ex
Normal file
236
lib/accent/scopes/translation.ex
Normal file
@ -0,0 +1,236 @@
|
||||
defmodule Accent.Scopes.Translation do
|
||||
import Ecto.Query
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.not_id(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.id != ^"test">
|
||||
"""
|
||||
@spec not_id(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def not_id(query, id) do
|
||||
from(t in query, where: t.id != ^id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Default ordering is by ascending key
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, nil)
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [asc: t.key]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "key")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [asc: t.key]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-key")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [desc: t.key]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "updated")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [asc: t.updated_at]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-updated")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [desc: t.updated_at]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "index")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [asc: t.file_index]>
|
||||
iex> Accent.Scopes.Translation.parse_order(Accent.Translation, "-index")
|
||||
#Ecto.Query<from t in Accent.Translation, order_by: [desc: t.file_index]>
|
||||
"""
|
||||
@spec parse_order(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def parse_order(query, "index"), do: from(t in query, order_by: [asc: :file_index])
|
||||
def parse_order(query, "-index"), do: from(t in query, order_by: [desc: :file_index])
|
||||
def parse_order(query, "key"), do: from(t in query, order_by: [asc: :key])
|
||||
def parse_order(query, "-key"), do: from(t in query, order_by: [desc: :key])
|
||||
def parse_order(query, "updated"), do: from(t in query, order_by: [asc: :updated_at])
|
||||
def parse_order(query, "-updated"), do: from(t in query, order_by: [desc: :updated_at])
|
||||
def parse_order(query, _), do: from(t in query, order_by: [asc: :key])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.active(Accent.Translation)
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.removed == false>
|
||||
"""
|
||||
@spec active(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def active(query), do: from(t in query, where: [removed: false])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, nil)
|
||||
Accent.Translation
|
||||
iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, false)
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.conflicted == false>
|
||||
iex> Accent.Scopes.Translation.parse_conflicted(Accent.Translation, true)
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.conflicted == true>
|
||||
"""
|
||||
@spec parse_conflicted(Ecto.Queryable.t(), nil | boolean()) :: Ecto.Queryable.t()
|
||||
def parse_conflicted(query, nil), do: query
|
||||
def parse_conflicted(query, false), do: not_conflicted(query)
|
||||
def parse_conflicted(query, true), do: conflicted(query)
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.conflicted(Accent.Translation)
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.conflicted == true>
|
||||
"""
|
||||
@spec conflicted(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def conflicted(query), do: from(t in query, where: [conflicted: true])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.not_conflicted(Accent.Translation)
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.conflicted == false>
|
||||
"""
|
||||
@spec not_conflicted(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def not_conflicted(query), do: from(t in query, where: [conflicted: false])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.no_version(Accent.Translation)
|
||||
#Ecto.Query<from t in Accent.Translation, where: is_nil(t.version_id)>
|
||||
"""
|
||||
@spec no_version(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def no_version(query), do: from_version(query, nil)
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_version(Accent.Translation, nil)
|
||||
#Ecto.Query<from t in Accent.Translation, where: is_nil(t.version_id)>
|
||||
iex> Accent.Scopes.Translation.from_version(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.version_id == ^"test">
|
||||
"""
|
||||
@spec from_version(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_version(query, nil), do: from(t in query, where: is_nil(t.version_id))
|
||||
def from_version(query, version_id), do: from(t in query, where: [version_id: ^version_id])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_revision(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.revision_id == ^"test">
|
||||
"""
|
||||
@spec from_revision(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_revision(query, revision_id), do: from(t in query, where: [revision_id: ^revision_id])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_revisions(Accent.Translation, ["test"])
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.revision_id in ^["test"]>
|
||||
"""
|
||||
@spec from_revision(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t()
|
||||
def from_revisions(query, revision_ids), do: from(t in query, where: t.revision_id in ^revision_ids)
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_project(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, left_join: p in assoc(t, :project), where: p.id == ^"test">
|
||||
"""
|
||||
@spec from_project(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_project(query, project_id) do
|
||||
from(
|
||||
translation in query,
|
||||
left_join: project in assoc(translation, :project),
|
||||
where: project.id == ^project_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_document(Accent.Translation, nil)
|
||||
#Ecto.Query<from t in Accent.Translation, where: is_nil(t.document_id)>
|
||||
iex> Accent.Scopes.Translation.from_document(Accent.Translation, :all)
|
||||
Accent.Translation
|
||||
iex> Accent.Scopes.Translation.from_document(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.document_id == ^"test">
|
||||
"""
|
||||
@spec from_document(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_document(query, nil), do: from(t in query, where: is_nil(t.document_id))
|
||||
def from_document(query, :all), do: query
|
||||
def from_document(query, document_id), do: from(t in query, where: [document_id: ^document_id])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_documents(Accent.Translation, ["test"])
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.document_id in ^["test"]>
|
||||
"""
|
||||
@spec from_documents(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t()
|
||||
def from_documents(query, document_ids), do: from(t in query, where: t.document_id in ^document_ids)
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_key(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.key == ^"test">
|
||||
"""
|
||||
@spec from_key(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t()
|
||||
def from_key(query, key), do: from(t in query, where: [key: ^key])
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_keys(Accent.Translation, ["test"])
|
||||
#Ecto.Query<from t in Accent.Translation, where: t.key in ^["test"]>
|
||||
"""
|
||||
@spec from_keys(Ecto.Queryable.t(), list(String.t())) :: Ecto.Queryable.t()
|
||||
def from_keys(query, key_ids), do: from(t in query, where: t.key in ^key_ids)
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.from_search(Accent.Translation, "")
|
||||
Accent.Translation
|
||||
iex> Accent.Scopes.Translation.from_search(Accent.Translation, nil)
|
||||
Accent.Translation
|
||||
iex> Accent.Scopes.Translation.from_search(Accent.Translation, 1234)
|
||||
Accent.Translation
|
||||
iex> Accent.Scopes.Translation.from_search(Accent.Translation, "test")
|
||||
#Ecto.Query<from t in Accent.Translation, where: ilike(t.key, ^\"%test%\") or ilike(t.corrected_text, ^\"%test%\")>
|
||||
iex> Accent.Scopes.Translation.from_search(Accent.Translation, "030519c4-1d47-42bb-95ee-205880be01d9")
|
||||
#Ecto.Query<from t in Accent.Translation, where: ilike(t.key, ^\"%030519c4-1d47-42bb-95ee-205880be01d9%\") or ilike(t.corrected_text, ^\"%030519c4-1d47-42bb-95ee-205880be01d9%\"), or_where: t.id == ^\"030519c4-1d47-42bb-95ee-205880be01d9\">
|
||||
"""
|
||||
@spec from_search(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_search(query, nil), do: query
|
||||
def from_search(query, term) when term === "", do: query
|
||||
def from_search(query, term) when not is_binary(term), do: query
|
||||
|
||||
def from_search(query, search_term) do
|
||||
term = "%" <> search_term <> "%"
|
||||
|
||||
from(
|
||||
translation in query,
|
||||
where: ilike(translation.key, ^term) or ilike(translation.corrected_text, ^term)
|
||||
)
|
||||
|> from_search_id(search_term)
|
||||
end
|
||||
|
||||
defp from_search_id(query, key) do
|
||||
case Ecto.UUID.cast(key) do
|
||||
{:ok, uuid} -> from(t in query, or_where: [id: ^uuid])
|
||||
_ -> query
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Translation.select_key_text(Accent.Translation)
|
||||
#Ecto.Query<from t in Accent.Translation, select: %{id: t.id, key: t.key, updated_at: t.updated_at, corrected_text: t.corrected_text}>
|
||||
"""
|
||||
@spec select_key_text(Ecto.Queryable.t()) :: Ecto.Queryable.t()
|
||||
def select_key_text(query) do
|
||||
from(
|
||||
translation in query,
|
||||
select: %{
|
||||
id: translation.id,
|
||||
key: translation.key,
|
||||
updated_at: translation.updated_at,
|
||||
corrected_text: translation.corrected_text
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
33
lib/accent/scopes/version.ex
Normal file
33
lib/accent/scopes/version.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule Accent.Scopes.Version do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Version.from_project(Accent.Version, "test")
|
||||
#Ecto.Query<from v in Accent.Version, where: v.project_id == ^"test">
|
||||
iex> Accent.Scopes.Version.from_project(Accent.Version, nil)
|
||||
Accent.Version
|
||||
"""
|
||||
@spec from_project(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_project(query, nil), do: query
|
||||
|
||||
def from_project(query, project_id) do
|
||||
from(v in query, where: [project_id: ^project_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.Scopes.Version.from_tag(Accent.Version, "test")
|
||||
#Ecto.Query<from v in Accent.Version, where: v.tag == ^"test">
|
||||
iex> Accent.Scopes.Version.from_tag(Accent.Version, nil)
|
||||
Accent.Version
|
||||
"""
|
||||
@spec from_tag(Ecto.Queryable.t(), any()) :: Ecto.Queryable.t()
|
||||
def from_tag(query, nil), do: query
|
||||
|
||||
def from_tag(query, tag) do
|
||||
from(v in query, where: [tag: ^tag])
|
||||
end
|
||||
end
|
65
lib/accent/translations/translations_counter.ex
Normal file
65
lib/accent/translations/translations_counter.ex
Normal file
@ -0,0 +1,65 @@
|
||||
defmodule Accent.TranslationsCounter do
|
||||
@moduledoc """
|
||||
From documents or revisions, computed the active and conflicted translations count.
|
||||
It counts them in an efficient way (no n+1 queries).
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Accent.Repo
|
||||
alias Accent.Scopes.Translation, as: Scope
|
||||
alias Accent.Translation
|
||||
|
||||
@spec from_documents(list(Accent.Document.t())) :: struct
|
||||
def from_documents(documents) do
|
||||
from_assoc(documents, :document_id, &Scope.from_documents/2)
|
||||
end
|
||||
|
||||
@spec from_revisions(list(Accent.Revision.t())) :: struct
|
||||
def from_revisions(revisions) do
|
||||
from_assoc(revisions, :revision_id, &Scope.from_revisions/2)
|
||||
end
|
||||
|
||||
defp from_assoc(associations, assoc_name, scope_filter_ids) do
|
||||
scope =
|
||||
Translation
|
||||
|> Scope.active()
|
||||
|> Scope.no_version()
|
||||
|
||||
active =
|
||||
scope
|
||||
|> select([t], %{id: field(t, ^assoc_name), active: count(t.id)})
|
||||
|> group_items(associations, assoc_name, scope_filter_ids)
|
||||
|> Repo.all()
|
||||
|
||||
conflicted =
|
||||
scope
|
||||
|> Scope.conflicted()
|
||||
|> select([t], %{id: field(t, ^assoc_name), conflicted: count(t.id)})
|
||||
|> group_items(associations, assoc_name, scope_filter_ids)
|
||||
|> Repo.all()
|
||||
|
||||
active
|
||||
|> Kernel.++(conflicted)
|
||||
|> Enum.group_by(&Map.get(&1, :id))
|
||||
|> Enum.reduce(%{}, &count_from_items/2)
|
||||
end
|
||||
|
||||
defp count_from_items({key, items}, acc) do
|
||||
case items do
|
||||
[%{active: active}, %{conflicted: conflicted}] ->
|
||||
Map.put_new(acc, key, %{conflicted: conflicted, active: active})
|
||||
|
||||
[%{active: active}] ->
|
||||
Map.put_new(acc, key, %{conflicted: 0, active: active})
|
||||
end
|
||||
end
|
||||
|
||||
defp group_items(query, associations, assoc_name, scope_filter_ids) do
|
||||
association_ids = Enum.map(associations, &Map.get(&1, :id))
|
||||
|
||||
from(t in query)
|
||||
|> scope_filter_ids.(association_ids)
|
||||
|> group_by([t], field(t, ^assoc_name))
|
||||
end
|
||||
end
|
58
lib/accent/translations/translations_renderer.ex
Normal file
58
lib/accent/translations/translations_renderer.ex
Normal file
@ -0,0 +1,58 @@
|
||||
defmodule Accent.TranslationsRenderer do
|
||||
alias Langue.Formatter.Strings.Serializer, as: StringsSerializer
|
||||
alias Langue.Formatter.Rails.Serializer, as: RailsSerializer
|
||||
alias Langue.Formatter.Json.Serializer, as: JsonSerializer
|
||||
alias Langue.Formatter.SimpleJson.Serializer, as: SimpleJsonSerializer
|
||||
alias Langue.Formatter.Es6Module.Serializer, as: Es6ModuleSerializer
|
||||
alias Langue.Formatter.Android.Serializer, as: AndroidSerializer
|
||||
alias Langue.Formatter.JavaProperties.Serializer, as: JavaPropertiesSerializer
|
||||
alias Langue.Formatter.JavaPropertiesXml.Serializer, as: JavaPropertiesXmlSerializer
|
||||
alias Langue.Formatter.Gettext.Serializer, as: GettextSerializer
|
||||
|
||||
def render(args) do
|
||||
serializer = fetch_serializer(args[:document_format])
|
||||
entries = fetch_entries(args[:translations])
|
||||
|
||||
parser_result = %Langue.Formatter.ParserResult{
|
||||
entries: entries,
|
||||
locale: args[:document_locale],
|
||||
top_of_the_file_comment: args[:document_top_of_the_file_comment],
|
||||
header: args[:document_header]
|
||||
}
|
||||
|
||||
try do
|
||||
serializer.(parser_result)
|
||||
catch
|
||||
_ -> Langue.Formatter.SerializerResult.empty()
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_serializer(format) do
|
||||
case serializer_from_format(format) do
|
||||
{:ok, serializer} -> serializer
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_entries(translations) do
|
||||
Enum.map(translations, fn translation ->
|
||||
%Langue.Entry{
|
||||
key: translation.key,
|
||||
value: translation.corrected_text,
|
||||
comment: translation.file_comment,
|
||||
index: translation.file_index,
|
||||
value_type: translation.value_type
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp serializer_from_format("strings"), do: {:ok, &StringsSerializer.serialize/1}
|
||||
defp serializer_from_format("rails_yml"), do: {:ok, &RailsSerializer.serialize/1}
|
||||
defp serializer_from_format("json"), do: {:ok, &JsonSerializer.serialize/1}
|
||||
defp serializer_from_format("simple_json"), do: {:ok, &SimpleJsonSerializer.serialize/1}
|
||||
defp serializer_from_format("android_xml"), do: {:ok, &AndroidSerializer.serialize/1}
|
||||
defp serializer_from_format("es6_module"), do: {:ok, &Es6ModuleSerializer.serialize/1}
|
||||
defp serializer_from_format("java_properties"), do: {:ok, &JavaPropertiesSerializer.serialize/1}
|
||||
defp serializer_from_format("java_properties_xml"), do: {:ok, &JavaPropertiesXmlSerializer.serialize/1}
|
||||
defp serializer_from_format("gettext"), do: {:ok, &GettextSerializer.serialize/1}
|
||||
defp serializer_from_format(_), do: {:error, :unknown_serializer}
|
||||
end
|
53
lib/accessible.ex
Normal file
53
lib/accessible.ex
Normal file
@ -0,0 +1,53 @@
|
||||
defmodule Accessible do
|
||||
@moduledoc false
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote location: :keep do
|
||||
@behaviour Access
|
||||
|
||||
def fetch(struct, key), do: Map.fetch(struct, key)
|
||||
|
||||
def get(struct, key, default \\ nil) do
|
||||
case struct do
|
||||
%{^key => value} -> value
|
||||
_else -> default
|
||||
end
|
||||
end
|
||||
|
||||
def put(struct, key, val) do
|
||||
if Map.has_key?(struct, key) do
|
||||
Map.put(struct, key, val)
|
||||
else
|
||||
struct
|
||||
end
|
||||
end
|
||||
|
||||
def delete(struct, key) do
|
||||
put(struct, key, struct(__MODULE__)[key])
|
||||
end
|
||||
|
||||
def get_and_update(struct, key, fun) when is_function(fun, 1) do
|
||||
current = get(struct, key)
|
||||
|
||||
case fun.(current) do
|
||||
{value, update} ->
|
||||
{value, put(struct, key, update)}
|
||||
|
||||
:pop ->
|
||||
{current, delete(struct, key)}
|
||||
|
||||
other ->
|
||||
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
|
||||
end
|
||||
end
|
||||
|
||||
def pop(struct, key, default \\ nil) do
|
||||
val = get(struct, key, default)
|
||||
updated = delete(struct, key)
|
||||
{val, updated}
|
||||
end
|
||||
|
||||
defoverridable fetch: 2, get: 3, put: 3, delete: 2, get_and_update: 3, pop: 3
|
||||
end
|
||||
end
|
||||
end
|
56
lib/graphql/datetime_scalar.ex
Normal file
56
lib/graphql/datetime_scalar.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule Accent.GraphQL.DatetimeScalar do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
@moduledoc """
|
||||
This module contains additional data types.
|
||||
To use: `import_types Absinthe.Type.Extensions`.
|
||||
"""
|
||||
|
||||
@utc_timezone "Etc/UTC"
|
||||
|
||||
scalar :datetime, name: "DateTime" do
|
||||
description("""
|
||||
The `DateTime` scalar type represents a date and time in the UTC
|
||||
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
|
||||
string, including UTC timezone ("Z").
|
||||
""")
|
||||
|
||||
serialize(&serialize_datetime/1)
|
||||
parse(parse_with([Absinthe.Blueprint.Input.DateTime], &parse_datetime/1))
|
||||
end
|
||||
|
||||
@spec parse_datetime(any) :: {:ok, DateTime.t()} | :error
|
||||
defp parse_datetime(value) when is_binary(value) do
|
||||
NaiveDateTime.from_iso8601(value)
|
||||
end
|
||||
|
||||
defp parse_datetime(_) do
|
||||
:error
|
||||
end
|
||||
|
||||
@spec serialize_datetime(any) :: {:ok, String.t()} | :error
|
||||
defp serialize_datetime(datetime = %NaiveDateTime{}) do
|
||||
datetime
|
||||
|> DateTime.from_naive!(@utc_timezone)
|
||||
|> DateTime.to_iso8601()
|
||||
end
|
||||
|
||||
defp serialize_datetime(_) do
|
||||
:error
|
||||
end
|
||||
|
||||
# Parse, supporting pulling values out of blueprint Input nodes
|
||||
defp parse_with(node_types, coercion) do
|
||||
fn
|
||||
%{__struct__: str, value: value} ->
|
||||
if Enum.member?(node_types, str) do
|
||||
coercion.(value)
|
||||
else
|
||||
:error
|
||||
end
|
||||
|
||||
other ->
|
||||
coercion.(other)
|
||||
end
|
||||
end
|
||||
end
|
151
lib/graphql/helpers/authorization.ex
Normal file
151
lib/graphql/helpers/authorization.ex
Normal file
@ -0,0 +1,151 @@
|
||||
defmodule Accent.GraphQL.Helpers.Authorization do
|
||||
import Accent.GraphQL.Plugins.Authorization
|
||||
|
||||
alias Accent.{
|
||||
Project,
|
||||
Version,
|
||||
Translation,
|
||||
Revision,
|
||||
Collaborator,
|
||||
TranslationCommentsSubscription,
|
||||
Document,
|
||||
Operation,
|
||||
Integration,
|
||||
Repo
|
||||
}
|
||||
|
||||
def viewer_authorize(_action, func) do
|
||||
fn
|
||||
%{user: nil}, _args, _info ->
|
||||
{:ok, nil}
|
||||
|
||||
user, args, info ->
|
||||
func.(user, args, info)
|
||||
end
|
||||
end
|
||||
|
||||
def project_authorize(action, func, id \\ :id) do
|
||||
fn
|
||||
project = %Project{}, args, info ->
|
||||
authorize(action, project.id, info, do: func.(project, args, info))
|
||||
|
||||
_, args, info ->
|
||||
project = Repo.get(Project, args[id]) || %{id: nil}
|
||||
|
||||
authorize(action, project.id, info, do: func.(project, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def revision_authorize(action, func) do
|
||||
fn
|
||||
revision = %Revision{}, args, info ->
|
||||
authorize(action, revision.project_id, info, do: func.(revision, args, info))
|
||||
|
||||
_, args, info ->
|
||||
revision =
|
||||
Revision
|
||||
|> Repo.get(args.id)
|
||||
|> Repo.preload(:language)
|
||||
|
||||
authorize(action, revision.project_id, info, do: func.(revision, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def version_authorize(action, func) do
|
||||
fn
|
||||
version = %Version{}, args, info ->
|
||||
authorize(action, version.project_id, info, do: func.(version, args, info))
|
||||
|
||||
_, args, info ->
|
||||
version =
|
||||
Version
|
||||
|> Repo.get(args.id)
|
||||
|
||||
authorize(action, version.project_id, info, do: func.(version, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def translation_authorize(action, func) do
|
||||
fn
|
||||
translation = %Translation{}, args, info ->
|
||||
revision =
|
||||
case translation.revision do
|
||||
%Revision{} = revision ->
|
||||
revision
|
||||
|
||||
_ ->
|
||||
translation |> Ecto.assoc(:revision) |> Repo.one()
|
||||
end
|
||||
|
||||
authorize(action, revision.project_id, info, do: func.(translation, args, info))
|
||||
|
||||
_, args, info ->
|
||||
id = args[:id] || args[:translation_id]
|
||||
|
||||
translation =
|
||||
Translation
|
||||
|> Repo.get(id)
|
||||
|> Repo.preload(revision: [:project])
|
||||
|
||||
authorize(action, translation.revision.project_id, info, do: func.(translation, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def document_authorize(action, func) do
|
||||
fn _, args, info ->
|
||||
document = Repo.get(Document, args.id)
|
||||
|
||||
authorize(action, document.project_id, info, do: func.(document, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def operation_authorize(action, func) do
|
||||
fn _, args, info ->
|
||||
operation =
|
||||
Operation
|
||||
|> Repo.get(args[:id])
|
||||
|> Repo.preload([:revision, [translation: [:revision]]])
|
||||
|
||||
project_id =
|
||||
case operation do
|
||||
%{translation: %{revision: %{project_id: id}}} -> id
|
||||
%{revision: %{project_id: id}} -> id
|
||||
%{project_id: id} -> id
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
authorize(action, project_id, info, do: func.(operation, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def translation_comment_subscription_authorize(action, func) do
|
||||
fn _, args, info ->
|
||||
subscription =
|
||||
TranslationCommentsSubscription
|
||||
|> Repo.get(args.id)
|
||||
|> Repo.preload(translation: [:revision])
|
||||
|
||||
authorize(action, subscription.translation.revision.project_id, info, do: func.(subscription, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def collaborator_authorize(action, func) do
|
||||
fn _, args, info ->
|
||||
collaborator =
|
||||
Collaborator
|
||||
|> Repo.get(args.id)
|
||||
|
||||
authorize(action, collaborator.project_id, info, do: func.(collaborator, args, info))
|
||||
end
|
||||
end
|
||||
|
||||
def integration_authorize(action, func) do
|
||||
fn _, args, info ->
|
||||
integration =
|
||||
Integration
|
||||
|> Repo.get(args.id)
|
||||
|
||||
authorize(action, integration.project_id, info, do: func.(integration, args, info))
|
||||
end
|
||||
end
|
||||
end
|
15
lib/graphql/helpers/fields.ex
Normal file
15
lib/graphql/helpers/fields.ex
Normal file
@ -0,0 +1,15 @@
|
||||
defmodule Accent.GraphQL.Helpers.Fields do
|
||||
@doc """
|
||||
## Examples
|
||||
|
||||
iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{foo: "alias"}, nil, nil)
|
||||
{:ok, "alias"}
|
||||
iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{foo: %{map: "alias"}}, nil, nil)
|
||||
{:ok, %{map: "alias"}}
|
||||
iex> Accent.GraphQL.Helpers.Fields.field_alias(:foo).(%{}, nil, nil)
|
||||
{:ok, nil}
|
||||
"""
|
||||
def field_alias(field) do
|
||||
fn root, _, _ -> {:ok, Map.get(root, field)} end
|
||||
end
|
||||
end
|
30
lib/graphql/mutations/collaborator.ex
Normal file
30
lib/graphql/mutations/collaborator.ex
Normal file
@ -0,0 +1,30 @@
|
||||
defmodule Accent.GraphQL.Mutations.Collaborator do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
alias Accent.GraphQL.Resolvers.Collaborator, as: CollaboratorResolver
|
||||
|
||||
object :collaborator_mutations do
|
||||
field :create_collaborator, :mutated_collaborator do
|
||||
arg(:project_id, non_null(:id))
|
||||
arg(:role, non_null(:role))
|
||||
arg(:email, non_null(:string))
|
||||
|
||||
resolve(project_authorize(:create_collaborator, &CollaboratorResolver.create/3, :project_id))
|
||||
end
|
||||
|
||||
field :update_collaborator, :mutated_collaborator do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:role, non_null(:role))
|
||||
|
||||
resolve(collaborator_authorize(:update_collaborator, &CollaboratorResolver.update/3))
|
||||
end
|
||||
|
||||
field :delete_collaborator, :mutated_collaborator do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(collaborator_authorize(:delete_collaborator, &CollaboratorResolver.delete/3))
|
||||
end
|
||||
end
|
||||
end
|
27
lib/graphql/mutations/comment.ex
Normal file
27
lib/graphql/mutations/comment.ex
Normal file
@ -0,0 +1,27 @@
|
||||
defmodule Accent.GraphQL.Mutations.Comment do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
object :comment_mutations do
|
||||
field :create_comment, :mutated_comment do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:text, non_null(:string))
|
||||
|
||||
resolve(translation_authorize(:create_comment, &Accent.GraphQL.Resolvers.Comment.create/3))
|
||||
end
|
||||
|
||||
field :create_translation_comments_subscription, :mutated_translation_comments_subscription do
|
||||
arg(:translation_id, non_null(:id))
|
||||
arg(:user_id, non_null(:id))
|
||||
|
||||
resolve(translation_authorize(:create_translation_comments_subscription, &Accent.GraphQL.Resolvers.TranslationCommentSubscription.create/3))
|
||||
end
|
||||
|
||||
field :delete_translation_comments_subscription, :mutated_translation_comments_subscription do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(translation_comment_subscription_authorize(:delete_translation_comments_subscription, &Accent.GraphQL.Resolvers.TranslationCommentSubscription.delete/3))
|
||||
end
|
||||
end
|
||||
end
|
13
lib/graphql/mutations/document.ex
Normal file
13
lib/graphql/mutations/document.ex
Normal file
@ -0,0 +1,13 @@
|
||||
defmodule Accent.GraphQL.Mutations.Document do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
object :document_mutations do
|
||||
field :delete_document, :mutated_document do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(document_authorize(:delete_document, &Accent.GraphQL.Resolvers.Document.delete/3))
|
||||
end
|
||||
end
|
||||
end
|
38
lib/graphql/mutations/integration.ex
Normal file
38
lib/graphql/mutations/integration.ex
Normal file
@ -0,0 +1,38 @@
|
||||
defmodule Accent.GraphQL.Mutations.Integration do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
alias Accent.GraphQL.Resolvers.Integration, as: IntegrationResolver
|
||||
|
||||
input_object :integration_data_input do
|
||||
field(:id, :id)
|
||||
field(:url, non_null(:string))
|
||||
end
|
||||
|
||||
object :integration_mutations do
|
||||
field :create_integration, :mutated_integration do
|
||||
arg(:project_id, non_null(:id))
|
||||
arg(:service, non_null(:integration_service))
|
||||
arg(:events, non_null(list_of(non_null(:integration_event))))
|
||||
arg(:data, non_null(:integration_data_input))
|
||||
|
||||
resolve(project_authorize(:create_integration, &IntegrationResolver.create/3, :project_id))
|
||||
end
|
||||
|
||||
field :update_integration, :mutated_integration do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:service, :integration_service)
|
||||
arg(:events, non_null(list_of(non_null(:integration_event))))
|
||||
arg(:data, non_null(:integration_data_input))
|
||||
|
||||
resolve(integration_authorize(:update_integration, &IntegrationResolver.update/3))
|
||||
end
|
||||
|
||||
field :delete_integration, :mutated_integration do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(integration_authorize(:delete_integration, &IntegrationResolver.delete/3))
|
||||
end
|
||||
end
|
||||
end
|
13
lib/graphql/mutations/operation.ex
Normal file
13
lib/graphql/mutations/operation.ex
Normal file
@ -0,0 +1,13 @@
|
||||
defmodule Accent.GraphQL.Mutations.Operation do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
object :operation_mutations do
|
||||
field :rollback_operation, :mutated_operation do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(operation_authorize(:rollback, &Accent.GraphQL.Resolvers.Operation.rollback/3))
|
||||
end
|
||||
end
|
||||
end
|
30
lib/graphql/mutations/project.ex
Normal file
30
lib/graphql/mutations/project.ex
Normal file
@ -0,0 +1,30 @@
|
||||
defmodule Accent.GraphQL.Mutations.Project do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
alias Accent.GraphQL.Resolvers.Project, as: ProjectResolver
|
||||
|
||||
object :project_mutations do
|
||||
field :create_project, :mutated_project do
|
||||
arg(:name, non_null(:string))
|
||||
arg(:language_id, non_null(:id))
|
||||
|
||||
resolve(&Accent.GraphQL.Resolvers.Project.create/3)
|
||||
end
|
||||
|
||||
field :update_project, :mutated_project do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:name, non_null(:string))
|
||||
arg(:is_file_operations_locked, :boolean)
|
||||
|
||||
resolve(project_authorize(:update_project, &ProjectResolver.update/3))
|
||||
end
|
||||
|
||||
field :delete_project, :mutated_project do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(project_authorize(:delete_project, &ProjectResolver.delete/3))
|
||||
end
|
||||
end
|
||||
end
|
40
lib/graphql/mutations/revision.ex
Normal file
40
lib/graphql/mutations/revision.ex
Normal file
@ -0,0 +1,40 @@
|
||||
defmodule Accent.GraphQL.Mutations.Revision do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
alias Accent.GraphQL.Resolvers.Revision, as: RevisionResolver
|
||||
|
||||
object :revision_mutations do
|
||||
field :create_revision, :mutated_revision do
|
||||
arg(:project_id, non_null(:id))
|
||||
arg(:language_id, non_null(:id))
|
||||
|
||||
resolve(project_authorize(:create_slave, &RevisionResolver.create/3, :project_id))
|
||||
end
|
||||
|
||||
field :delete_revision, :mutated_revision do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(revision_authorize(:delete_slave, &RevisionResolver.delete/3))
|
||||
end
|
||||
|
||||
field :promote_revision_master, :mutated_revision do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(revision_authorize(:promote_slave, &RevisionResolver.promote_master/3))
|
||||
end
|
||||
|
||||
field :correct_all_revision, :mutated_revision do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(revision_authorize(:correct_all_revision, &RevisionResolver.correct_all/3))
|
||||
end
|
||||
|
||||
field :uncorrect_all_revision, :mutated_revision do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(revision_authorize(:uncorrect_all_revision, &RevisionResolver.uncorrect_all/3))
|
||||
end
|
||||
end
|
||||
end
|
29
lib/graphql/mutations/translation.ex
Normal file
29
lib/graphql/mutations/translation.ex
Normal file
@ -0,0 +1,29 @@
|
||||
defmodule Accent.GraphQL.Mutations.Translation do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
alias Accent.GraphQL.Resolvers.Translation, as: TranslationResolver
|
||||
|
||||
object :translation_mutations do
|
||||
field :correct_translation, :mutated_translation do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:text, non_null(:string))
|
||||
|
||||
resolve(translation_authorize(:correct_translation, &TranslationResolver.correct/3))
|
||||
end
|
||||
|
||||
field :uncorrect_translation, :mutated_translation do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(translation_authorize(:uncorrect_translation, &TranslationResolver.uncorrect/3))
|
||||
end
|
||||
|
||||
field :update_translation, :mutated_translation do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:text, :string)
|
||||
|
||||
resolve(translation_authorize(:update_translation, &TranslationResolver.update/3))
|
||||
end
|
||||
end
|
||||
end
|
23
lib/graphql/mutations/version.ex
Normal file
23
lib/graphql/mutations/version.ex
Normal file
@ -0,0 +1,23 @@
|
||||
defmodule Accent.GraphQL.Mutations.Version do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Accent.GraphQL.Helpers.Authorization
|
||||
|
||||
object :version_mutations do
|
||||
field :create_version, :mutated_version do
|
||||
arg(:project_id, non_null(:id))
|
||||
arg(:name, non_null(:string))
|
||||
arg(:tag, non_null(:string))
|
||||
|
||||
resolve(project_authorize(:create_version, &Accent.GraphQL.Resolvers.Version.create/3, :project_id))
|
||||
end
|
||||
|
||||
field :update_version, :mutated_version do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:name, non_null(:string))
|
||||
arg(:tag, non_null(:string))
|
||||
|
||||
resolve(version_authorize(:update_version, &Accent.GraphQL.Resolvers.Version.update/3))
|
||||
end
|
||||
end
|
||||
end
|
43
lib/graphql/paginated.ex
Normal file
43
lib/graphql/paginated.ex
Normal file
@ -0,0 +1,43 @@
|
||||
defmodule Accent.GraphQL.Paginated do
|
||||
defmodule Meta do
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@enforce_keys [:current_page, :total_pages, :total_entries, :next_page, :previous_page]
|
||||
defstruct current_page: 0, total_entries: 0, total_pages: 0, next_page: nil, previous_page: nil
|
||||
end
|
||||
|
||||
@type t(list_of_type) :: %__MODULE__{entries: [list_of_type], meta: Meta.t()}
|
||||
|
||||
@enforce_keys [:entries, :meta]
|
||||
defstruct entries: [], meta: %{}
|
||||
|
||||
use Accessible
|
||||
|
||||
def format(paginated_list) do
|
||||
%__MODULE__{entries: paginated_list.entries, meta: meta(paginated_list)}
|
||||
end
|
||||
|
||||
defp meta(%{page_size: page_size, total_entries: total_entries, total_pages: total_pages, page_number: page_number}) do
|
||||
%Meta{
|
||||
current_page: page_number,
|
||||
total_entries: total_entries,
|
||||
total_pages: total_pages,
|
||||
next_page: build_next_page(page_size, total_entries, total_pages, page_number),
|
||||
previous_page: build_previous_page(page_size, total_entries, total_pages, page_number)
|
||||
}
|
||||
end
|
||||
|
||||
defp build_next_page(_page_size, _entries, 1, _page), do: nil
|
||||
defp build_next_page(_page_size, _entries, pages, page) when page >= pages, do: nil
|
||||
|
||||
defp build_next_page(page_size, entries, _pages, page) do
|
||||
if page_size * page < entries, do: page + 1
|
||||
end
|
||||
|
||||
defp build_previous_page(_page_size, _entries, _pages, 1), do: nil
|
||||
defp build_previous_page(_page_size, _entries, 1, _page), do: nil
|
||||
|
||||
defp build_previous_page(page_size, entries, _pages, page) do
|
||||
if page_size * page < entries + page_size, do: page - 1
|
||||
end
|
||||
end
|
12
lib/graphql/plugins/authorization.ex
Normal file
12
lib/graphql/plugins/authorization.ex
Normal file
@ -0,0 +1,12 @@
|
||||
defmodule Accent.GraphQL.Plugins.Authorization do
|
||||
defmacro authorize(action, id, info, do: do_clause) do
|
||||
quote do
|
||||
with current_user when not is_nil(current_user) <- unquote(info).context[:conn].assigns[:current_user],
|
||||
true <- Canada.Can.can?(current_user, unquote(action), unquote(id)) do
|
||||
unquote(do_clause)
|
||||
else
|
||||
_ -> {:ok, nil}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
26
lib/graphql/resolvers/access_token.ex
Normal file
26
lib/graphql/resolvers/access_token.ex
Normal file
@ -0,0 +1,26 @@
|
||||
defmodule Accent.GraphQL.Resolvers.AccessToken do
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Accent.{
|
||||
AccessToken,
|
||||
Plugs.GraphQLContext,
|
||||
Project,
|
||||
Repo
|
||||
}
|
||||
|
||||
@spec show_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil}
|
||||
def show_project(project, _, _) do
|
||||
from(
|
||||
access_token in AccessToken,
|
||||
inner_join: user in assoc(access_token, :user),
|
||||
inner_join: collaboration in assoc(user, :collaborations),
|
||||
where: collaboration.project_id == ^project.id,
|
||||
where: user.bot == true
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
%AccessToken{token: token} -> {:ok, token}
|
||||
_ -> {:ok, nil}
|
||||
end
|
||||
end
|
||||
end
|
52
lib/graphql/resolvers/activity.ex
Normal file
52
lib/graphql/resolvers/activity.ex
Normal file
@ -0,0 +1,52 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Activity do
|
||||
require Ecto.Query
|
||||
alias Ecto.Query
|
||||
|
||||
alias Accent.Scopes.Operation, as: OperationScope
|
||||
|
||||
alias Accent.{
|
||||
GraphQL.Paginated,
|
||||
Operation,
|
||||
Plugs.GraphQLContext,
|
||||
Project,
|
||||
Repo,
|
||||
Translation
|
||||
}
|
||||
|
||||
@spec list_project(Project.t(), map(), GraphQLContext.t()) :: {:ok, Paginated.t(Operation.t())}
|
||||
def list_project(project, args, _) do
|
||||
Operation
|
||||
|> OperationScope.ignore_actions(args[:action], args[:is_batch])
|
||||
|> OperationScope.filter_from_user(args[:user_id])
|
||||
|> OperationScope.filter_from_batch(args[:is_batch])
|
||||
|> OperationScope.filter_from_action(args[:action])
|
||||
|> Query.join(:left, [o], r in assoc(o, :revision))
|
||||
|> Query.where([o, r], r.project_id == ^project.id or o.project_id == ^project.id)
|
||||
|> OperationScope.order_last_to_first()
|
||||
|> Repo.paginate(page: args[:page], page_size: args[:page_size])
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
@spec list_translation(Translation.t(), map(), GraphQLContext.t()) :: {:ok, Paginated.t(Operation.t())}
|
||||
def list_translation(translation, args, _) do
|
||||
translation
|
||||
|> Ecto.assoc(:operations)
|
||||
|> OperationScope.filter_from_user(args[:user_id])
|
||||
|> OperationScope.filter_from_batch(args[:is_batch])
|
||||
|> OperationScope.filter_from_action(args[:action])
|
||||
|> Query.where([o, _], o.action not in ["update_proposed"])
|
||||
|> OperationScope.order_last_to_first()
|
||||
|> Repo.paginate(page: args[:page])
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
@spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Operation.t() | nil}
|
||||
def show_project(_project, %{id: id}, _info) do
|
||||
Operation
|
||||
|> Query.where(id: ^id)
|
||||
|> Repo.one()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
end
|
64
lib/graphql/resolvers/collaborator.ex
Normal file
64
lib/graphql/resolvers/collaborator.ex
Normal file
@ -0,0 +1,64 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Collaborator do
|
||||
alias Accent.{
|
||||
Repo,
|
||||
Project,
|
||||
Collaborator,
|
||||
CollaboratorCreator,
|
||||
CollaboratorUpdater,
|
||||
Hook,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@typep collaborator_operation :: {:ok, %{collaborator: Collaborator.t() | nil, errors: [String.t()] | nil}}
|
||||
|
||||
@broadcaster Application.get_env(:accent, :hook_broadcaster)
|
||||
|
||||
@spec create(Project.t(), %{email: String.t(), role: String.t()}, GraphQLContext.t()) :: collaborator_operation
|
||||
def create(project, %{email: email, role: role}, info) do
|
||||
params = %{
|
||||
"email" => email,
|
||||
"role" => role,
|
||||
"project_id" => project.id,
|
||||
"assigner_id" => info.context[:conn].assigns[:current_user].id
|
||||
}
|
||||
|
||||
case CollaboratorCreator.create(params) do
|
||||
{:ok, collaborator} ->
|
||||
@broadcaster.fanout(%Hook.Context{
|
||||
event: "create_collaborator",
|
||||
project: project,
|
||||
user: info.context[:conn].assigns[:current_user],
|
||||
payload: %{
|
||||
collaborator: collaborator
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, %{collaborator: collaborator, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec update(Collaborator.t(), %{role: String.t()}, GraphQLContext.t()) :: collaborator_operation
|
||||
def update(collaborator, %{role: role}, _info) do
|
||||
case CollaboratorUpdater.update(collaborator, %{"role" => role}) do
|
||||
{:ok, collaborator} ->
|
||||
{:ok, %{collaborator: collaborator, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete(Collaborator.t(), map(), GraphQLContext.t()) :: collaborator_operation
|
||||
def delete(collaborator, _, _) do
|
||||
case Repo.delete(collaborator) do
|
||||
{:ok, _collaborator} ->
|
||||
{:ok, %{collaborator: collaborator, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{collaborator: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
end
|
66
lib/graphql/resolvers/comment.ex
Normal file
66
lib/graphql/resolvers/comment.ex
Normal file
@ -0,0 +1,66 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Comment do
|
||||
alias Accent.Scopes.Comment, as: CommentScope
|
||||
|
||||
alias Accent.{
|
||||
Repo,
|
||||
Comment,
|
||||
Translation,
|
||||
Project,
|
||||
Hook,
|
||||
GraphQL.Paginated,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@typep comment_operation :: {:ok, %{comment: Comment.t() | nil, errors: [String.t()] | nil}}
|
||||
|
||||
@broadcaster Application.get_env(:accent, :hook_broadcaster)
|
||||
|
||||
@spec create(Translation.t(), %{text: String.t()}, GraphQLContext.t()) :: comment_operation
|
||||
def create(translation, args, info) do
|
||||
comment_params = %{
|
||||
"text" => args.text,
|
||||
"user_id" => info.context[:conn].assigns[:current_user].id,
|
||||
"translation_id" => translation.id
|
||||
}
|
||||
|
||||
changeset = Comment.changeset(%Comment{}, comment_params)
|
||||
|
||||
case Repo.insert(changeset) do
|
||||
{:ok, comment} ->
|
||||
comment = Repo.preload(comment, [:user, translation: [revision: :project]])
|
||||
|
||||
@broadcaster.fanout(%Hook.Context{
|
||||
event: "create_comment",
|
||||
project: comment.translation.revision.project,
|
||||
user: info.context[:conn].assigns[:current_user],
|
||||
payload: %{
|
||||
comment: comment
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, %{comment: comment, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{comment: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_project(Project.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Comment.t())}
|
||||
def list_project(project, args, _) do
|
||||
Comment
|
||||
|> CommentScope.from_project(project.id)
|
||||
|> Repo.paginate(page: args[:page])
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
@spec list_translation(Translation.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Comment.t())}
|
||||
def list_translation(translation, args, _) do
|
||||
translation
|
||||
|> Ecto.assoc(:comments)
|
||||
|> CommentScope.default_order()
|
||||
|> Repo.paginate(page: args[:page])
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
end
|
72
lib/graphql/resolvers/document.ex
Normal file
72
lib/graphql/resolvers/document.ex
Normal file
@ -0,0 +1,72 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Document do
|
||||
require Ecto.Query
|
||||
|
||||
alias Accent.{
|
||||
Document,
|
||||
GraphQL.Paginated,
|
||||
Plugs.GraphQLContext,
|
||||
Project,
|
||||
Repo,
|
||||
TranslationsCounter
|
||||
}
|
||||
|
||||
alias Accent.Scopes.Document, as: DocumentScope
|
||||
|
||||
alias Movement.Builders.DocumentDelete, as: DocumentDeleteBuilder
|
||||
alias Movement.Context
|
||||
alias Movement.Persisters.DocumentDelete, as: DocumentDeletePersister
|
||||
|
||||
@typep document_operation :: {:ok, %{document: Document.t() | nil, errors: [String.t()] | nil}}
|
||||
|
||||
@spec delete(Document.t(), any(), GraphQLContext.t()) :: document_operation
|
||||
def delete(document, _, info) do
|
||||
%Context{}
|
||||
|> Context.assign(:document, document)
|
||||
|> Context.assign(:user_id, info.context[:conn].assigns[:current_user].id)
|
||||
|> DocumentDeleteBuilder.build()
|
||||
|> DocumentDeletePersister.persist()
|
||||
|> case do
|
||||
{:ok, _} ->
|
||||
{:ok, %{document: document, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{document: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec show_project(Project.t(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Document.t() | nil}
|
||||
def show_project(project, %{id: id}, _) do
|
||||
Document
|
||||
|> DocumentScope.from_project(project.id)
|
||||
|> Ecto.Query.where(id: ^id)
|
||||
|> Repo.one()
|
||||
|> merge_stats()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
@spec list_project(Project.t(), %{page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Document.t())}
|
||||
def list_project(project, args, _) do
|
||||
Document
|
||||
|> DocumentScope.from_project(project.id)
|
||||
|> Ecto.Query.order_by(desc: :updated_at)
|
||||
|> Repo.paginate(page: args[:page])
|
||||
|> update_in([Access.key(:entries)], &merge_stats/1)
|
||||
|> update_in([Access.key(:entries)], fn entries -> Enum.filter(entries, fn document -> document.translations_count > 0 end) end)
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
defp merge_stats(document) when is_map(document) do
|
||||
counts = TranslationsCounter.from_documents([document])
|
||||
|
||||
document
|
||||
|> Document.merge_stats(counts)
|
||||
end
|
||||
|
||||
defp merge_stats(documents) when is_list(documents) do
|
||||
counts = TranslationsCounter.from_documents(documents)
|
||||
|
||||
documents
|
||||
|> Enum.map(&Document.merge_stats(&1, counts))
|
||||
end
|
||||
end
|
9
lib/graphql/resolvers/document_format.ex
Normal file
9
lib/graphql/resolvers/document_format.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule Accent.GraphQL.Resolvers.DocumentFormat do
|
||||
alias Accent.{
|
||||
DocumentFormat,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@spec list(any(), map(), GraphQLContext.t()) :: {:ok, list(DocumentFormat.t())}
|
||||
def list(_, _, _), do: {:ok, DocumentFormat.all()}
|
||||
end
|
40
lib/graphql/resolvers/integration.ex
Normal file
40
lib/graphql/resolvers/integration.ex
Normal file
@ -0,0 +1,40 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Integration do
|
||||
alias Accent.{
|
||||
Project,
|
||||
Integration,
|
||||
IntegrationManager,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@typep integration_operation :: {:ok, %{integration: Integration.t() | nil, errors: [String.t()] | nil}}
|
||||
|
||||
@spec create(Project.t(), map(), GraphQLContext.t()) :: integration_operation
|
||||
def create(project, args, info) do
|
||||
args =
|
||||
args
|
||||
|> Map.put(:project_id, project.id)
|
||||
|> Map.put(:user_id, info.context[:conn].assigns[:current_user].id)
|
||||
|
||||
resolve(IntegrationManager.create(args))
|
||||
end
|
||||
|
||||
@spec update(Integration.t(), map(), GraphQLContext.t()) :: integration_operation
|
||||
def update(integration, args, _info) do
|
||||
resolve(IntegrationManager.update(integration, args))
|
||||
end
|
||||
|
||||
@spec delete(Integration.t(), map(), GraphQLContext.t()) :: integration_operation
|
||||
def delete(integration, _args, _info) do
|
||||
resolve(IntegrationManager.delete(integration))
|
||||
end
|
||||
|
||||
defp resolve(result) do
|
||||
case result do
|
||||
{:ok, integration} ->
|
||||
{:ok, %{integration: integration, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{integration: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
end
|
21
lib/graphql/resolvers/language.ex
Normal file
21
lib/graphql/resolvers/language.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Language do
|
||||
alias Accent.{
|
||||
Repo,
|
||||
Language,
|
||||
GraphQL.Paginated,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
alias Accent.Scopes.Language, as: LanguageScope
|
||||
|
||||
@page_size 10
|
||||
|
||||
@spec list(any(), %{page: number(), query: String.t()}, GraphQLContext.t()) :: {:ok, Paginated.t(Language.t())}
|
||||
def list(_, args, _) do
|
||||
Language
|
||||
|> LanguageScope.from_search(args[:query])
|
||||
|> Repo.paginate(page: args[:page], page_size: @page_size)
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
end
|
24
lib/graphql/resolvers/operation.ex
Normal file
24
lib/graphql/resolvers/operation.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Operation do
|
||||
alias Movement.Builders.Rollback, as: RollbackBuilder
|
||||
alias Movement.Persisters.Rollback, as: RollbackPersister
|
||||
|
||||
alias Accent.{
|
||||
Operation,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@spec rollback(Operation.t(), any(), GraphQLContext.t()) :: {:ok, %{operation: boolean(), errors: [String.t()] | nil}}
|
||||
def rollback(operation, _, info) do
|
||||
operation = Accent.Repo.preload(operation, :batch_operation)
|
||||
|
||||
%Movement.Context{}
|
||||
|> Movement.Context.assign(:operation, operation)
|
||||
|> Movement.Context.assign(:user_id, info.context[:conn].assigns[:current_user].id)
|
||||
|> RollbackBuilder.build()
|
||||
|> RollbackPersister.persist()
|
||||
|> case do
|
||||
{:ok, _} -> {:ok, %{operation: true, errors: nil}}
|
||||
{:error, _} -> {:ok, %{operation: false, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
end
|
16
lib/graphql/resolvers/permission.ex
Normal file
16
lib/graphql/resolvers/permission.ex
Normal file
@ -0,0 +1,16 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Permission do
|
||||
alias Accent.{
|
||||
Project,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
@spec list_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, [atom()]}
|
||||
def list_project(project, _, %{context: context}) do
|
||||
permissions =
|
||||
context[:conn].assigns[:current_user].permissions
|
||||
|> Map.get(project.id)
|
||||
|> Accent.RoleAbilities.actions_for()
|
||||
|
||||
{:ok, permissions}
|
||||
end
|
||||
end
|
80
lib/graphql/resolvers/project.ex
Normal file
80
lib/graphql/resolvers/project.ex
Normal file
@ -0,0 +1,80 @@
|
||||
defmodule Accent.GraphQL.Resolvers.Project do
|
||||
require Ecto.Query
|
||||
|
||||
alias Accent.Scopes.Project, as: ProjectScope
|
||||
|
||||
alias Accent.{
|
||||
Repo,
|
||||
Project,
|
||||
ProjectCreator,
|
||||
ProjectUpdater,
|
||||
ProjectDeleter,
|
||||
User,
|
||||
GraphQL.Paginated,
|
||||
Plugs.GraphQLContext
|
||||
}
|
||||
|
||||
alias Ecto.Query
|
||||
|
||||
@typep project_operation :: {:ok, %{project: Project.t() | nil, errors: [String.t()] | nil}}
|
||||
|
||||
@spec create(any(), %{name: String.t(), language_id: String.t()}, GraphQLContext.t()) :: project_operation
|
||||
def create(_, %{name: name, language_id: language_id}, info) do
|
||||
params = %{
|
||||
"name" => name,
|
||||
"language_id" => language_id
|
||||
}
|
||||
|
||||
case ProjectCreator.create(params: params, user: info.context[:conn].assigns[:current_user]) do
|
||||
{:ok, project} ->
|
||||
{:ok, %{project: project, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{project: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete(Project.t(), any(), GraphQLContext.t()) :: project_operation
|
||||
def delete(project, _, _) do
|
||||
{:ok, _} = ProjectDeleter.delete(project: project)
|
||||
|
||||
{:ok, %{project: project, errors: nil}}
|
||||
end
|
||||
|
||||
@spec update(Project.t(), %{name: String.t(), is_file_operations_locked: boolean() | nil}, GraphQLContext.t()) :: project_operation
|
||||
def update(project, %{name: name, is_file_operations_locked: locked_file_operations}, info) do
|
||||
params = %{
|
||||
"name" => name,
|
||||
"locked_file_operations" => locked_file_operations
|
||||
}
|
||||
|
||||
case ProjectUpdater.update(project: project, params: params, user: info.context[:conn].assigns[:current_user]) do
|
||||
{:ok, project} ->
|
||||
{:ok, %{project: project, errors: nil}}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:ok, %{project: nil, errors: ["unprocessable_entity"]}}
|
||||
end
|
||||
end
|
||||
|
||||
def update(project, %{name: name}, info), do: update(project, %{name: name, is_file_operations_locked: nil}, info)
|
||||
|
||||
@spec list_viewer(User.t(), %{query: String.t(), page: number()}, GraphQLContext.t()) :: {:ok, Paginated.t(Project.t())}
|
||||
def list_viewer(viewer, args, _info) do
|
||||
Project
|
||||
|> Query.join(:inner, [p], c in assoc(p, :collaborators))
|
||||
|> Query.where([_, c], c.user_id == ^viewer.id)
|
||||
|> Query.order_by([p, _], asc: p.name)
|
||||
|> ProjectScope.from_search(args[:query])
|
||||
|> Repo.paginate(page: args[:page])
|
||||
|> Paginated.format()
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
|
||||
@spec show_viewer(any(), %{id: String.t()}, GraphQLContext.t()) :: {:ok, Project.t() | nil}
|
||||
def show_viewer(_, %{id: id}, _) do
|
||||
Project
|
||||
|> Repo.get(id)
|
||||
|> (&{:ok, &1}).()
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user