Initial commit 💥

This commit is contained in:
Simon Prévost 2018-04-05 16:47:36 -04:00
commit bca59c4caa
926 changed files with 53737 additions and 0 deletions

65
.credo.exs Normal file
View 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
View File

@ -0,0 +1,4 @@
[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
line_length: 180
]

26
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
source 'https://rubygems.org'
group :development do
gem 'hanzo'
end

15
Gemfile.lock Normal file
View 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
View 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.

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: mix phoenix.server

124
README.md Normal file
View 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**. Accents 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. Its 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 dont already have it, install `nodejs` with `brew install nodejs`
1. If you dont already have it, install `elixir` with `brew install elixir`
2. If you dont already have it, install `libyaml` with `brew install libyaml`
2. If you dont 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. Thats 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 clients 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 dont 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!
Dont 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
View File

@ -0,0 +1,2 @@
cd $phoenix_dir
npm --prefix ./webapp run build-production

67
config/config.exs Normal file
View 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 absinthes 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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
View File

@ -0,0 +1,3 @@
defmodule Accent.Mailer do
use Bamboo.Mailer, otp_app: :accent
end

View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,7 @@
defmodule Accent.IntegrationData do
use Accent.Schema
schema "" do
field(:url)
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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