diff --git a/Gemfile b/Gemfile index 822c4dd0b..bbe94468b 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ group :development, :test do # comment over to use visual debugger (eg. RubyMine), uncomment to use manual debugging # gem 'byebug' gem 'dotenv-rails' + gem 'pry' end group :development do @@ -43,7 +44,6 @@ group :development do # Preview mail in the browser gem 'listen', '~> 3.0.5' gem 'overcommit' - gem 'pry' gem 'rb-readline' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'railroady' @@ -149,3 +149,5 @@ gem 'acts_as_list' # Error reporting gem 'sentry-rails' gem 'sentry-ruby' + +gem "reverse_markdown" diff --git a/Gemfile.lock b/Gemfile.lock index 75449b5b4..cd14fbf34 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -398,6 +398,8 @@ GEM responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) + reverse_markdown (2.1.1) + nokogiri rexml (3.2.5) rolify (5.3.0) rubocop (1.31.2) @@ -590,6 +592,7 @@ DEPENDENCIES redis-session-store repost responders (~> 3.0) + reverse_markdown rolify rubocop (~> 1.31) rubocop-rails diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 9bde77ed2..a844f7ab3 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -18,6 +18,12 @@ class API::ProjectsController < API::APIController @project = Project.friendly.find(params[:id]) end + def markdown + @project = Project.friendly.find(params[:id]) + authorize @project + send_data ProjectToMarkdown.new(@project).call, filename: "#{@project.name.parameterize}-#{@project.id}.md", disposition: 'attachment', type: 'text/markdown' + end + def create @project = Project.new(project_params.merge(author_statistic_profile_id: current_user.statistic_profile.id)) if @project.save @@ -53,12 +59,23 @@ class API::ProjectsController < API::APIController def search service = ProjectService.new - res = service.search(params, current_user) + paginate = request.format.zip? ? false : true + res = service.search(params, current_user, paginate: paginate) + render json: res, status: :unprocessable_entity and return if res[:error] - @total = res[:total] - @projects = res[:projects] - render :index + respond_to do |format| + format.json do + @total = res[:total] + @projects = res[:projects] + render :index + end + format.zip do + head :forbidden unless current_user.admin? || current_user.manager? + + send_data ProjectsArchive.new(res[:projects]).call, filename: "projets.zip", disposition: 'attachment', type: 'application/zip' + end + end end private diff --git a/app/frontend/src/javascript/controllers/projects.js b/app/frontend/src/javascript/controllers/projects.js index 69c463759..de52f50f5 100644 --- a/app/frontend/src/javascript/controllers/projects.js +++ b/app/frontend/src/javascript/controllers/projects.js @@ -380,6 +380,8 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P $scope.triggerSearch(); }; + $scope.zipUrl = '/api/projects/search.zip'; + /** * Callback triggered when the button "search from the whole network" is toggled */ @@ -481,6 +483,7 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P const toDate = search.to_date ? search.to_date.toDateString() : undefined; updateUrlParam('to_date', toDate); updateUrlParam('project_category_id', search.project_category_id); + $scope.zipUrl = '/api/projects/search.zip?' + new URLSearchParams({ search: JSON.stringify($location.search()) }).toString(); return true; }; diff --git a/app/frontend/templates/projects/index.html b/app/frontend/templates/projects/index.html index 62471ce31..276258d3e 100644 --- a/app/frontend/templates/projects/index.html +++ b/app/frontend/templates/projects/index.html @@ -106,6 +106,12 @@ + +
+ + {{ 'app.public.projects_list.download_archive' | translate }} + +
diff --git a/app/frontend/templates/projects/show.html b/app/frontend/templates/projects/show.html index ecc3666a4..12b4467d7 100644 --- a/app/frontend/templates/projects/show.html +++ b/app/frontend/templates/projects/show.html @@ -1,199 +1,202 @@
-
+
+
+
+
+ +
+
+
+
+

{{ project.name }} {{ 'app.public.projects_show.rough_draft' }}

+
+
+ + +
+
+ +
-
-
- -
-
-
-
-

{{ project.name }} {{ 'app.public.projects_show.rough_draft' }}

-
-
- - -
-
- - -
-
- -
- -
- {{project.name}} -
- -

{{ 'app.public.projects_show.project_description' }}

-

- -
-
-
-

{{ 'app.public.projects_show.step_N' | translate:{INDEX:step.step_nb} }} : {{step.title}}

-
-
-
- {{image.attachment}} -
-
- -

-
+
+
+
+ {{project.name}}
+ +

{{ 'app.public.projects_show.project_description' }}

+

+ +
+
+
+

{{ 'app.public.projects_show.step_N' | translate:{INDEX:step.step_nb} }} : {{step.title}}

+
+
+
+ {{image.attachment}} +
+
+ +

+
+ + +
+
+ +
+ + + +
+ +
- - -
- - -
- -
- -
+
-
-
- +
+
+ +
+ + {{ 'app.public.projects_show.posted_on_' | translate }} {{project.created_at | amDateFormat: 'LL'}} + +
+ + {{theme.name}} + +
+
-
- - {{ 'app.public.projects_show.by_name' | translate:{NAME:project.author.first_name} }} + + +
+
+ {{project.project_caos_attributes.length}} +

{{ 'app.public.projects_show.CAD_file_to_download' }}

+
+ +
+
+ +
+
+

{{ 'app.public.projects_show.status' }}

+
+
+ {{ project.status.name }} +
+
+ +
+
+ {{project.machines.length}} +

{{ 'app.public.projects_show.machines_and_materials' }}

+
+ + + +
    +
  • + {{component.name}} +
  • +
+
+ +
+
+ {{project.project_users.length}} +

{{ 'app.public.projects_show.collaborators' }}

+
+ +
    + +
+
+ +
+
+

{{ 'app.public.projects_show.licence' }}

+
+
+ {{ project.licence.name }} +
+
+ +
+
+

{{ 'app.shared.project.tags' }}

+
+
+
{{ project.tags }}
+
+
+ +
+
+

{{ projectCategoriesWording }}

+
+ +
    +
  • + {{projectCategory.name}} +
  • +
+
+ +
+ + {{ 'app.public.projects_show.markdown_file' | translate }} - {{ 'app.public.projects_show.deleted_user' }} -
- {{ 'app.public.projects_show.posted_on_' | translate }} {{project.created_at | amDateFormat: 'LL'}} - -
- - {{theme.name}} -
-
- - -
-
- {{project.project_caos_attributes.length}} -

{{ 'app.public.projects_show.CAD_file_to_download' }}

-
- - -
- -
-
-

{{ 'app.public.projects_show.status' }}

-
-
- {{ project.status.name }} -
-
- -
-
- {{project.machines.length}} -

{{ 'app.public.projects_show.machines_and_materials' }}

-
- - - -
    -
  • - {{component.name}} -
  • -
-
- -
-
- {{project.project_users.length}} -

{{ 'app.public.projects_show.collaborators' }}

-
- -
    - -
-
- -
-
-

{{ 'app.public.projects_show.licence' }}

-
-
- {{ project.licence.name }} -
-
- -
-
-

{{ 'app.shared.project.tags' }}

-
-
-
{{ project.tags }}
-
-
- -
-
-

{{ projectCategoriesWording }}

-
- -
    -
  • - {{projectCategory.name}} -
  • -
-
- -
- +
-
+
+
- - -
diff --git a/app/models/project_step.rb b/app/models/project_step.rb index 0324c54f2..b9b76a248 100644 --- a/app/models/project_step.rb +++ b/app/models/project_step.rb @@ -5,4 +5,6 @@ class ProjectStep < ApplicationRecord belongs_to :project, touch: true has_many :project_step_images, as: :viewable, dependent: :destroy accepts_nested_attributes_for :project_step_images, allow_destroy: true, reject_if: :all_blank + + default_scope -> { order(:step_nb) } end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 8daad8143..8da49c747 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -16,10 +16,14 @@ class ProjectPolicy < ApplicationPolicy end def update? - user.admin? or record.author.user_id == user.id or record.users.include?(user) + user.admin? || record.author.user_id == user.id || record.users.include?(user) + end + + def markdown? + user.admin? || user.manager? || record.author.user_id == user.id || record.users.include?(user) end def destroy? - user.admin? or record.author.user_id == user.id + user.admin? || record.author.user_id == user.id end end diff --git a/app/services/project_service.rb b/app/services/project_service.rb index a0d0bdb8a..0e6abf32b 100644 --- a/app/services/project_service.rb +++ b/app/services/project_service.rb @@ -2,17 +2,17 @@ # Provides methods for Project class ProjectService - def search(params, current_user) + def search(params, current_user, paginate: true) connection = ActiveRecord::Base.connection return { error: 'invalid adapter' } unless connection.instance_values['config'][:adapter] == 'postgresql' - search_from_postgre(params, current_user) + search_from_postgre(params, current_user, paginate: paginate) end private - def search_from_postgre(params, current_user) - query_params = JSON.parse(params[:search]) + def search_from_postgre(params, current_user, paginate: true) + query_params = JSON.parse(params[:search] || "{}") records = Project.published_or_drafts(current_user&.statistic_profile&.id) records = Project.user_projects(current_user&.statistic_profile&.id) if query_params['from'] == 'mine' @@ -44,6 +44,9 @@ class ProjectService records.order(created_at: :desc) end - { total: records.count, projects: records.includes(:users, :project_image).page(params[:page]) } + records = records.includes(:users, :project_image) + records = records.page(params[:page]) if paginate + + { total: records.count, projects: records } end end diff --git a/app/services/project_to_markdown.rb b/app/services/project_to_markdown.rb new file mode 100644 index 000000000..7f3521ff0 --- /dev/null +++ b/app/services/project_to_markdown.rb @@ -0,0 +1,88 @@ +class ProjectToMarkdown + attr_reader :project + + def initialize(project) + @project = project + end + + def call + md = [] + + md << "# #{project.name}" + + md << "![#{I18n.t('app.shared.project.illustration')}](#{full_url(project.project_image.attachment.url)})" if project.project_image + + md << ReverseMarkdown.convert(project.description.to_s) + + project_steps = project.project_steps + + if project_steps.present? + md << "## #{I18n.t('app.shared.project.steps')}" + + project_steps.each do |project_step| + md << "### #{I18n.t('app.shared.project.step_N').gsub('{INDEX}', project_step.step_nb.to_s)} : #{project_step.title}" + md << ReverseMarkdown.convert(project_step.description.to_s) + + project_step.project_step_images.each_with_index do |image, i| + md << "![#{I18n.t('app.shared.project.step_image')} #{i+1}](#{full_url(project.project_image.attachment.url)})" + end + end + end + + md << "## #{I18n.t('app.shared.project.author')}" + md << project.author&.user&.profile&.full_name + + if project.themes.present? + md << "## #{I18n.t('app.shared.project.themes')}" + md << project.themes.map(&:name).join(', ') + end + + if project.project_caos.present? + md << "## #{I18n.t('app.shared.project.CAD_files')}" + project.project_caos.each do |cao| + md << "![#{cao.attachment_identifier}](#{full_url(cao.attachment_url)})" + end + end + + if project.status + md << "## #{I18n.t('app.shared.project.status')}" + md << project.status.name + end + + if project.machines.present? + md << "## #{I18n.t('app.shared.project.employed_machines')}" + md << project.machines.map(&:name).join(', ') + end + + if project.components.present? + md << "## #{I18n.t('app.shared.project.employed_materials')}" + md << project.components.map(&:name).join(', ') + end + + if project.users.present? + md << "## #{I18n.t('app.shared.project.collaborators')}" + md << project.users.map { |u| u.profile.full_name }.join(', ') + end + + if project.licence.present? + md << "## #{I18n.t('app.shared.project.licence')}" + md << project.licence.name + end + + if project.tags.present? + md << "## #{I18n.t('app.shared.project.tags')}" + md << project.tags + end + + + md = md.reject { |line| line.blank? } + + md.join("\n\n") + end + + private + + def full_url(path) + "#{Rails.application.routes.url_helpers.root_url[...-1]}#{path}" + end +end \ No newline at end of file diff --git a/app/services/projects_archive.rb b/app/services/projects_archive.rb new file mode 100644 index 000000000..009ea661f --- /dev/null +++ b/app/services/projects_archive.rb @@ -0,0 +1,22 @@ +class ProjectsArchive + attr_reader :projects + + def initialize(projects) + @projects = projects + end + + def call + stringio = Zip::OutputStream.write_buffer do |zio| + projects.includes(:project_image, :themes, + :project_caos, :status, :machines, + :components, :licence, + project_steps: :project_step_images, + author: { user: :profile }, + users: :profile).find_each do |project| + zio.put_next_entry("#{project.name.parameterize}-#{project.id}.md") + zio.write ProjectToMarkdown.new(project).call + end + end + stringio.string + end +end \ No newline at end of file diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 4afd8a93c..c90cbf0de 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -187,6 +187,7 @@ en: filter_by_member: "Filter by member" created_from: Created from created_to: Created to + download_archive: Download status_filter: all_statuses: "All statuses" select_status: "Select a status" @@ -220,6 +221,7 @@ en: report: "Report" do_you_really_want_to_delete_this_project: "Do you really want to delete this project?" status: "Status" + markdown_file: "Markdown file" #list of machines machines_list: the_fablab_s_machines: "The machines" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 9fe6efb2e..bff82e15c 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -187,6 +187,7 @@ fr: filter_by_member: "Filter par membre" created_from: Créés à partir du created_to: Créés jusqu'au + download_archive: Télécharger status_filter: all_statuses: "Tous les statuts" select_status: "Sélectionnez un statut" @@ -220,6 +221,7 @@ fr: report: "Signaler" do_you_really_want_to_delete_this_project: "Êtes-vous sur de vouloir supprimer ce projet ?" status: "Statut" + markdown_file: "Fichier Markdown" #list of machines machines_list: the_fablab_s_machines: "Les machines" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 423e3a6f9..bfe88a627 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -131,6 +131,7 @@ en: illustration: "Visual" add_an_illustration: "Add an illustration" CAD_file: "CAD file" + CAD_files: "CAD files" allowed_extensions: "Allowed extensions:" add_a_new_file: "Add a new file" description: "Description" @@ -138,6 +139,7 @@ en: steps: "Steps" step_N: "Step {INDEX}" step_title: "Step title" + step_image: "Image" add_a_picture: "Add a picture" change_the_picture: "Change the picture" delete_the_step: "Delete the step" @@ -149,7 +151,9 @@ en: employed_materials: "Employed materials" employed_machines: "Employed machines" collaborators: "Collaborators" + author: Author creative_commons_licences: "Creative Commons licences" + licence: "Licence" themes: "Themes" tags: "Tags" save_as_draft: "Save as draft" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index e03e762b3..2c655d255 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -131,6 +131,7 @@ fr: illustration: "Illustration" add_an_illustration: "Ajouter un visuel" CAD_file: "Fichier CAO" + CAD_files: "Fichiers CAO" allowed_extensions: "Extensions autorisées :" add_a_new_file: "Ajouter un nouveau fichier" description: "Description" @@ -138,6 +139,7 @@ fr: steps: "Étapes" step_N: "Étape {INDEX}" step_title: "Titre de l'étape" + step_image: "Image" add_a_picture: "Ajouter une image" change_the_picture: "Modifier l'image" delete_the_step: "Supprimer l'étape" @@ -149,7 +151,9 @@ fr: employed_materials: "Matériaux utilisés" employed_machines: "Machines utilisées" collaborators: "Les collaborateurs" + author: Auteur creative_commons_licences: "Licences Creative Commons" + licence: "Licence" themes: "Thématiques" tags: "Étiquettes" save_as_draft: "Enregistrer comme brouillon" diff --git a/config/routes.rb b/config/routes.rb index a813bcc5f..f4ce0b5af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ Rails.application.routes.draw do get :last_published get :search end + get :markdown, on: :member end resources :openlab_projects, only: :index resources :machines diff --git a/test/fixtures/project_steps.yml b/test/fixtures/project_steps.yml index cc2516cba..96d44b4fb 100644 --- a/test/fixtures/project_steps.yml +++ b/test/fixtures/project_steps.yml @@ -7,6 +7,7 @@ project_step_1: created_at: 2016-04-04 15:39:08.259759000 Z updated_at: 2016-04-04 15:39:08.259759000 Z title: Le manche + step_nb: 1 project_step_2: id: 2 @@ -16,3 +17,4 @@ project_step_2: created_at: 2016-04-04 15:39:08.265840000 Z updated_at: 2016-04-04 15:39:08.265840000 Z title: La presse + step_nb: 2 \ No newline at end of file diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml index 07e635e93..cec0d59b7 100644 --- a/test/fixtures/projects.yml +++ b/test/fixtures/projects.yml @@ -12,3 +12,4 @@ project_1: state: published slug: presse-puree published_at: 2016-04-04 15:39:08.267614000 Z + status_id: 1 \ No newline at end of file diff --git a/test/integration/projects_test.rb b/test/integration/projects_test.rb new file mode 100644 index 000000000..60477000f --- /dev/null +++ b/test/integration/projects_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ProjectsTest < ActionDispatch::IntegrationTest + def setup + @admin = User.find_by(username: 'admin') + login_as(@admin, scope: :user) + end + + test 'download markdown file' do + get "/api/projects/1/markdown" + + assert_response :success + assert_equal "text/markdown", response.content_type + end +end \ No newline at end of file diff --git a/test/services/project_to_markdown_test.rb b/test/services/project_to_markdown_test.rb new file mode 100644 index 000000000..388f615f1 --- /dev/null +++ b/test/services/project_to_markdown_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ProjectToMarkdownTest < ActiveSupport::TestCase + test "ProjectToMarkdown is working" do + project = projects(:project_1) + service = ProjectToMarkdown.new(project) + + markdown_str = nil + + assert_nothing_raised do + markdown_str = service.call + end + + assert_includes markdown_str, project.name + project.project_steps.each do |project_step| + assert_includes markdown_str, project_step.title + end + end +end