diff --git a/Gemfile b/Gemfile index 0463551..81d5688 100644 --- a/Gemfile +++ b/Gemfile @@ -99,6 +99,8 @@ gem 'strip_attributes' gem 'table_print', require: false # Email validations gem 'valid_email2' +# AWS S3 +gem 'aws-sdk-s3' gem 'json', '~> 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 258c214..90d80fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,22 @@ GEM ar_outer_joins (0.2.0) activerecord (>= 3.2) ast (2.4.1) + aws-eventstream (1.1.0) + aws-partitions (1.412.0) + aws-sdk-core (3.110.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.40.0) + aws-sdk-core (~> 3, >= 3.109.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.87.0) + aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.2) + aws-eventstream (~> 1, >= 1.0.2) backport (1.1.2) bcrypt (3.1.16) benchmark (0.1.1) @@ -142,6 +158,7 @@ GEM concurrent-ruby (~> 1.0) interception (0.5) jaro_winkler (1.5.4) + jmespath (1.4.0) json (2.5.1) json-schema (2.8.1) addressable (>= 2.4) @@ -387,6 +404,7 @@ DEPENDENCIES active_attr active_record_extended annotate + aws-sdk-s3 bcrypt (~> 3.1.7) better_errors binding_of_caller diff --git a/app/commands/send_grid_email.rb b/app/commands/send_grid_email.rb index 65d50f1..918ebc6 100644 --- a/app/commands/send_grid_email.rb +++ b/app/commands/send_grid_email.rb @@ -27,7 +27,15 @@ def pony_payload @pony_payload end + def logger + @logger ||= Logger.new(Rails.root.join('log', 'email.log')) + end + def work - Pony.mail(pony_payload) + if Rails.env.development? + logger.info(pony_payload.inspect) + else + Pony.mail(pony_payload) + end end end diff --git a/app/commands/upload_to_s3.rb b/app/commands/upload_to_s3.rb new file mode 100644 index 0000000..c657c45 --- /dev/null +++ b/app/commands/upload_to_s3.rb @@ -0,0 +1,27 @@ +# frozen_literal_string: true +class UploadToS3 < ApplicationCommand + attribute :content + attribute :path + attribute :filename + attribute :expires_in, default: 3600 + + validates :content, presence: true + validates :path, presence: true + validates :filename, presence: true + + def work + s3 = Aws::S3::Resource.new( + region: ENV['AWS_REGION'], + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'], + ) + + bucket = s3.bucket(ENV['AWS_BUCKET']) + obj = bucket.object(self.path) + obj.put(body: self.content, content_disposition: "attachment; filename=#{self.filename}") + + obj.presigned_url(:get, expires_in: self.expires_in) + rescue => e + puts e.inspect + end +end diff --git a/app/controllers/locations_controller.rb b/app/controllers/locations_controller.rb index 6c7f456..001ecfc 100644 --- a/app/controllers/locations_controller.rb +++ b/app/controllers/locations_controller.rb @@ -114,6 +114,15 @@ module LocationsController render json: Reports::Location.new(location).to_h end + post '/v1/locations/:location_id/people-report' do + location_id = params[:location_id] + location = Location.find_by_id_or_permalink(location_id) + ensure_or_forbidden! { location && current_user.admin_at?(location) } + + PeopleReportWorker.perform_async(location.id, current_user.id) + success_response + end + get '/v1/locations/:location_id/stats-overview/:date' do location = Location.find_by_id_or_permalink!(params[:location_id]) stats = LocationStatsOverview.new(location, params[:date]) diff --git a/app/models/greenlight_status.rb b/app/models/greenlight_status.rb index 675330f..cd4f3c5 100644 --- a/app/models/greenlight_status.rb +++ b/app/models/greenlight_status.rb @@ -48,6 +48,7 @@ class GreenlightStatus < ApplicationRecord scope :recently_created, -> { where(created_at: 20.days.ago.beginning_of_day..Time.zone.now.end_of_day) } scope :not_expired, -> { where('expiration_date <= ?', Time.current.to_date) } + scope :chronical, -> { order(submission_date: :asc) } has_many :medical_events before_validation :assign_associated_users_to_medical_events diff --git a/app/workers/people_report_worker.rb b/app/workers/people_report_worker.rb new file mode 100644 index 0000000..6a1485b --- /dev/null +++ b/app/workers/people_report_worker.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class PeopleReportWorker < ApplicationWorker + def perform(location_id, admin_id) + admin = User.find(admin_id) + location = Location.find(location_id) + + people_report = Reports::People.new(location) + s3_upload = UploadToS3.new( + content: people_report.csv, + path: "reports/#{location.permalink}/#{Time.now.strftime('%Y-%m-%d_%H_%M')}.csv", + filename: "report(#{location.permalink}).csv", + ) + s3_upload.run + + if s3_upload.succeeded? + download_url = s3_upload.result + + SendGridEmail.new( + to: admin.name_with_email, + subject: I18n.t('emails.people_report.subject'), + html: eval(html_template), + ).run + end + end + + private + + def html_template + Erubi::Engine.new(<<~HTML).src +

<%= I18n.t('emails.people_report.title') %>

+ +

+ <%= I18n.t('emails.people_report.body', location: location.name) %> +

+ +

+ + <%= I18n.t('emails.people_report.action') %> + +

+ HTML + end +end diff --git a/client/src/api/index.ts b/client/src/api/index.ts index d7bd0cd..2ca18f3 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -173,6 +173,10 @@ export async function updateLocationAccount( return entity } +export async function requestPeopleReport(location: Location) { + await v1.post(`/locations/${location.id}/people-report`) +} + export async function checkLocationRegistrationCode(locationId: string, registrationCode: string): Promise { const result = await v1.post(`/locations/${locationId}/check-registration-code`, { registrationCode, diff --git a/client/src/pages/admin/AdminDashboardPage.tsx b/client/src/pages/admin/AdminDashboardPage.tsx index 79eb8eb..8ff6849 100644 --- a/client/src/pages/admin/AdminDashboardPage.tsx +++ b/client/src/pages/admin/AdminDashboardPage.tsx @@ -1,6 +1,6 @@ import { - AccordionContent, - Block, Icon, List, ListItem, Navbar, Page, + AccordionContent, f7, + Block, Icon, List, ListItem, Navbar, Page, Button, } from 'framework7-react' import React from 'react' import { useEffect, useGlobal, useState } from 'reactn' @@ -13,11 +13,13 @@ import { dynamicPaths, paths } from 'src/config/routes' import { UsersFilter } from 'src/components/UsersFilter' import { assertNotNull, assertNotUndefined } from 'src/helpers/util' import { F7Props } from 'src/types' -import { getLocation, store, v1 } from 'src/api' +import { getLocation, store, v1, requestPeopleReport } from 'src/api' import { GreenlightStatus, Location } from 'src/models' import { GreenlightStatusTypes } from 'src/models/GreenlightStatus' import logger from 'src/helpers/logger' +import SubmitHandler from 'src/helpers/SubmitHandler' import FakeF7ListItem from 'src/components/FakeF7ListItem' +import Tr, { tr } from 'src/components/Tr' interface StatsSquareProps { title: string @@ -153,6 +155,20 @@ export default function AdminDashboardPage(props: F7Props): JSX.Element { ], }, } + + const reportHandler = new SubmitHandler(f7, { + onSubmit: async () => { + assertNotNull(state.location) + await requestPeopleReport(state.location) + }, + errorTitle: tr({ es: 'Solicitud fallida', en: 'Request failed.' }), + submittingMessage: tr({ es: 'Solicitando Informe ...', en: 'Requesting Report...' }), + successMessage: tr({ + es: 'El informe generado se enviará a su correo electrónico.', + en: 'The generated report will be sent to your email.', + }), + }) + content = ( <> @@ -254,6 +270,12 @@ export default function AdminDashboardPage(props: F7Props): JSX.Element { ) } + +

+ +

diff --git a/config/locales/en.yml b/config/locales/en.yml index a082684..426ce6f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -16,6 +16,13 @@ en: You requested to reset your password and here's the link! Note that this link expires in 1 hour and can only be used once. action: Click Here to Reset Password + people_report: + subject: ✨ Greenlight Report + title: Download the Report + body: | + "You requested a report for %{location} and here's the download link!" + Note that this link expires in 1 hour and can only be used once. + action: Click Here to Download Report reminder: subject: ✨ Daily Greenlight Reminder title: Check In with Greenlight diff --git a/lib/reports/people.rb b/lib/reports/people.rb new file mode 100644 index 0000000..0f492ef --- /dev/null +++ b/lib/reports/people.rb @@ -0,0 +1,80 @@ +# frozen_literal_string: true +require 'csv' + +module Reports + class People + SCHOOL_COLUMNS = "First Name, Last Name, Parent First Name, Parent Last Name, \ + Parent Email, Parent Phone, Submissions, Date Registered, \ + Last Date Yellow, Last Date Red, Last Test Date, \ + Last Test Result".split(', ').map(&:strip).freeze + OTHER_COLUMNS = "First Name, Last Name, Submissions, Date Registered, \ + Last Date Yellow, Last Date Red, Last Test Date, \ + Last Test Result".split(', ').map(&:strip).freeze + + def initialize(location) + @location = location + @users = @location.users.includes(:parents) + @is_school = @location.category == 'school' + end + + def csv + CSV.generate(headers: true) do |csv| + csv << (@is_school ? SCHOOL_COLUMNS : OTHER_COLUMNS) + + @users.each do |user| + csv << csv_row(user) + end + end + end + + private + + def csv_row(user) + parent = user.parents.first + submission_stats = submission_stats(user) + + user_row = [ user.first_name, user.last_name ] + + if @is_school + user_row << parent&.first_name + user_row << parent&.last_name + user_row << parent&.email + user_row << parent&.mobile_number + end + + user_row << submission_stats[:submissions].join(' ') + user_row << user.created_at.strftime('%Y-%m-%d') + user_row << submission_stats[:last_yellow_date] + user_row << submission_stats[:last_red_date] + user_row << submission_stats[:last_test_date] + user_row << submission_stats[:last_test_result] + end + + def submission_stats(user) + statuses_chronical = user.greenlight_statuses.chronical + + submissions = [] + last_yellow_date = nil + last_red_date = nil + last_greenlight_status = statuses_chronical.last + + statuses_chronical.each do |greenlight_status| + submissions << greenlight_status.status + + if greenlight_status.status == 'pending' + last_yellow_date = greenlight_status.submission_date + elsif greenlight_status.status == 'recovery' + last_red_date = greenlight_status.submission_date + end + end + + { + submissions: submissions, + last_yellow_date: last_yellow_date, + last_red_date: last_red_date, + last_test_date: last_greenlight_status&.submission_date, + last_test_result: last_greenlight_status&.status, + } + end + end +end diff --git a/spec/requests/locations_spec.rb b/spec/requests/locations_spec.rb index 9c4598d..4b01ff6 100644 --- a/spec/requests/locations_spec.rb +++ b/spec/requests/locations_spec.rb @@ -110,6 +110,42 @@ end end + describe 'POST :location_id/people-report' do + let(:admin) { Fabricate(:user) } + + before do + # skip actual uploading to S3 bucket, stub the operation + allow_any_instance_of(UploadToS3) + .to receive(:work).and_return(Faker::Internet.url) + end + + subject { post_json("/v1/locations/#{location.id}/people-report", user: admin) } + + it "returns 403 if a user isn't the admin of the location" do + subject + + expect(response.status).to eq(403) + end + + context 'when requested by the location admin' do + before do + Fabricate( + :location_account, + user: admin, + location: location, + permission_level: 'admin', + ) + end + + it "generates report and sends download link" do + subject + + expect_success_response + expect_work(PeopleReportWorker) + end + end + end + describe 'swagger specs' do path '/v1/locations/:location_id' do get 'return the specified location' do diff --git a/spec/workers/people_report_worker_spec.rb b/spec/workers/people_report_worker_spec.rb new file mode 100644 index 0000000..48d7baf --- /dev/null +++ b/spec/workers/people_report_worker_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe PeopleReportWorker do + let(:dummy_download_link) { Faker::Internet.url } + + before do + allow_any_instance_of(UploadToS3) + .to receive(:work).and_return(dummy_download_link) + end + + describe '#perform' do + let(:location) { Fabricate(:location) } + let(:admin) { Fabricate(:user) } + + before do + Fabricate( + :location_account, + user: admin, + location: location, + permission_level: 'admin', + ) + end + + it 'sends an email with the report download link' do + PeopleReportWorker.new.perform(location.id, admin.id) + + sent_mails = Mail::TestMailer.deliveries + expect(sent_mails.size).to eq(1) + + sent_mail = sent_mails.first + expect(sent_mail.To&.value).to eq(admin.name_with_email) + expect(sent_mail.html_part.to_s).to include(dummy_download_link) + end + end +end