diff --git a/.markdownlintrc b/.markdownlintrc
index d0f2c22d0..8384db3e4 100644
--- a/.markdownlintrc
+++ b/.markdownlintrc
@@ -7,8 +7,8 @@
"style": "sublist"
},
"MD013": {
- "code_blocks": false,
- "tables": false,
+ "line_length": 120,
+ "tables": false
},
"MD024": {
"allow_different_nesting": true
diff --git a/Gemfile b/Gemfile
index d062f24cb..71c1f49a3 100644
--- a/Gemfile
+++ b/Gemfile
@@ -26,6 +26,7 @@ gem 'aws-sdk-s3', require: false
gem 'image_processing', '~> 1.2'
# authentication
+gem 'authy'
gem 'devise'
# assets: core asset dependencies
@@ -58,10 +59,16 @@ gem 'telephone_number'
# error tracking
gem 'rollbar', github: 'rollbar/rollbar-gem'
+gem 'slack-notifier'
# permalinks
gem 'friendly_id', '~> 5.2.0'
+# sms
+gem 'nexmo'
+gem 'smstools'
+gem 'twilio-ruby', '~> 5.21.2'
+
group :development, :test do
gem 'brakeman', require: false
gem 'bullet', github: 'flyerhzm/bullet'
@@ -76,9 +83,8 @@ end
group :test do
gem 'capybara', '>= 2.15'
- gem 'chromedriver-helper'
gem 'codacy-coverage', require: false
- gem 'selenium-webdriver'
+ gem 'webdrivers', '~> 3.0'
gem 'webmock'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 2768aba95..aebacc2e9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -8,7 +8,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: efb706daad0e2e1039c6abb4879c837ef8bf4d10
+ revision: 4ec486145290efb4059b64e84a2bfd9e157bf787
specs:
actioncable (6.0.0.beta3)
actionpack (= 6.0.0.beta3)
@@ -63,7 +63,7 @@ GIT
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
- zeitwerk (~> 1.4)
+ zeitwerk (~> 1.4, >= 1.4.2)
rails (6.0.0.beta3)
actioncable (= 6.0.0.beta3)
actionmailbox (= 6.0.0.beta3)
@@ -129,11 +129,11 @@ GEM
public_suffix (>= 2.0.2, < 4.0)
ancestry (3.0.5)
activerecord (>= 3.2.0)
- archive-zip (0.12.0)
- io-like (~> 0.3.0)
ast (2.4.0)
+ authy (2.7.5)
+ httpclient (>= 2.5.3.3)
aws-eventstream (1.0.2)
- aws-partitions (1.145.0)
+ aws-partitions (1.146.0)
aws-sdk-core (3.48.2)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
@@ -142,7 +142,7 @@ GEM
aws-sdk-kms (1.16.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.34.0)
+ aws-sdk-s3 (1.35.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
@@ -177,9 +177,6 @@ GEM
chartkick (3.0.2)
childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11)
- chromedriver-helper (2.1.0)
- archive-zip (~> 0.10)
- nokogiri (~> 1.8)
cocoon (1.2.12)
codacy-coverage (2.1.0)
simplecov
@@ -219,6 +216,8 @@ GEM
smart_properties
erubi (1.8.0)
execjs (2.7.0)
+ faraday (0.15.4)
+ multipart-post (>= 1.2, < 3)
ffi (1.10.0)
fit-commit (3.8.1)
swearjar (~> 1.3)
@@ -232,18 +231,19 @@ GEM
activesupport (>= 4.2)
hashdiff (0.3.8)
html_tokenizer (0.0.7)
+ httpclient (2.8.3)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
image_processing (1.8.0)
mini_magick (>= 4.9.3, < 5)
ruby-vips (>= 2.0.13, < 3)
- io-like (0.3.0)
jaro_winkler (1.5.2)
jbuilder (2.8.0)
activesupport (>= 4.2.0)
multi_json (>= 1.2)
jmespath (1.4.0)
json (2.2.0)
+ jwt (2.1.0)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.7.0)
@@ -271,7 +271,11 @@ GEM
minitest (5.11.3)
msgpack (1.2.9)
multi_json (1.13.1)
+ multipart-post (2.0.0)
+ net_http_ssl_fix (0.0.10)
netaddr (2.0.3)
+ nexmo (5.6.0)
+ jwt (~> 2)
nio4r (2.3.1)
nokogiri (1.10.1)
mini_portile2 (~> 2.4.0)
@@ -365,7 +369,9 @@ GEM
simplecov-html (0.10.2)
simpleidn (0.1.1)
unf (~> 0.1.4)
+ slack-notifier (2.3.2)
smart_properties (1.13.1)
+ smstools (0.2.0)
spring (2.0.2)
activesupport (>= 4.2)
spring-watcher-listen (2.0.1)
@@ -383,6 +389,10 @@ GEM
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.9)
+ twilio-ruby (5.21.2)
+ faraday (~> 0.9)
+ jwt (>= 1.5, <= 2.5)
+ nokogiri (>= 1.6, < 2.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uglifier (4.1.20)
@@ -394,6 +404,11 @@ GEM
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
+ webdrivers (3.7.0)
+ net_http_ssl_fix
+ nokogiri (~> 1.6)
+ rubyzip (~> 1.0)
+ selenium-webdriver (~> 3.0)
webmock (3.5.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
@@ -409,13 +424,14 @@ GEM
activesupport
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (1.4.0)
+ zeitwerk (1.4.2)
PLATFORMS
ruby
DEPENDENCIES
ancestry
+ authy
aws-sdk-s3
bcrypt (~> 3.1.7)
bootsnap (>= 1.4.0)
@@ -425,7 +441,6 @@ DEPENDENCIES
byebug
capybara (>= 2.15)
chartkick
- chromedriver-helper
cocoon
codacy-coverage
coffee-rails (~> 4.2)
@@ -442,6 +457,7 @@ DEPENDENCIES
letter_opener
listen (>= 3.0.5, < 3.2)
lol_dba
+ nexmo
nokul-support!
nokul-tenant!
nokul-tenant-omu!
@@ -461,15 +477,18 @@ DEPENDENCIES
rubocop
ruby-progressbar
sassc-rails
- selenium-webdriver
sidekiq
simple_form
simplecov
+ slack-notifier
+ smstools
spring
spring-watcher-listen (~> 2.0.0)
telephone_number
+ twilio-ruby (~> 5.21.2)
uglifier (>= 1.3.0)
web-console!
+ webdrivers (~> 3.0)
webmock
webpacker
wicked_pdf
diff --git a/README.md b/README.md
index 2410b62a7..059179855 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,6 @@
[![Security](https://hakiri.io/projects/cf546402fb7117/stacks/622121c74c17f4/622121c74c17f4.svg?repo_token=xo-yGdqDKXvCf-jypXd-)](https://hakiri.io/projects/cf546402fb7117/stacks/622121c74c17f4/shield)
[![Known Vulnerabilities](https://snyk.io/test/github/omu/nokul/badge.svg)](https://snyk.io/test/github/omu/nokul)
-
[Nokul](https://github.com/omu/nokul) aims to become a complete solution for universities that face many challanges while trying to manage all the complex procedures of a university online.
Dealing with ill-structured processes of a university is hard, especially when they backed with complex legal procedures. Many universities facing similar issues when it comes to automating things, and moving face-to-face operations to online. Nokul aims to remediate these wounds by taking a Turkish university with more than 70 web services, 50K students and 4K employees as a case.
diff --git a/app/controllers/calendar_management/calendars_controller.rb b/app/controllers/calendar_management/calendars_controller.rb
index 9dc245831..cecea014a 100644
--- a/app/controllers/calendar_management/calendars_controller.rb
+++ b/app/controllers/calendar_management/calendars_controller.rb
@@ -43,14 +43,13 @@ def destroy
end
def duplicate
- @calendar = Calendar.find(params[:calendar_id])
- @duplicate_record = DuplicateService.new(@calendar, 'name').duplicate
-
- redirect_to(:calendars, alert: t('.warning')) && return unless @duplicate_record
+ calendar = Calendar.find(params[:calendar_id])
- AcademicCalendars::DuplicateEventsService.new(@calendar, @duplicate_record)
+ duplicate_record = AcademicCalendars::DuplicateCalendarService.new(calendar, 'name').duplicate
+ redirect_to(:calendars, alert: t('.warning')) && return unless duplicate_record
- redirect_to([:edit, @duplicate_record], notice: t('.success'))
+ AcademicCalendars::DuplicateEventsService.new(calendar, duplicate_record)
+ redirect_to([:edit, duplicate_record], notice: t('.success'))
end
def units
diff --git a/app/errors/concatenation_error.rb b/app/errors/concatenation_error.rb
new file mode 100644
index 000000000..93e381856
--- /dev/null
+++ b/app/errors/concatenation_error.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ConcatenationError < StandardError
+ def message
+ I18n.t('errors.can_not_be_concatenated')
+ end
+end
diff --git a/app/errors/encoding_mismatch_error.rb b/app/errors/encoding_mismatch_error.rb
new file mode 100644
index 000000000..171776b48
--- /dev/null
+++ b/app/errors/encoding_mismatch_error.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class EncodingMismatchError < StandardError
+ def message
+ I18n.t('errors.encoding_mismatch')
+ end
+end
diff --git a/app/errors/id_number_error.rb b/app/errors/id_number_error.rb
deleted file mode 100644
index b7b9f4fb6..000000000
--- a/app/errors/id_number_error.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class IdNumberError < StandardError
- def message
- I18n.t('errors.invalid_id')
- end
-end
diff --git a/app/errors/invalid_phone_number_error.rb b/app/errors/invalid_phone_number_error.rb
new file mode 100644
index 000000000..a9e1be762
--- /dev/null
+++ b/app/errors/invalid_phone_number_error.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class InvalidPhoneNumberError < StandardError
+ def message
+ I18n.t('errors.invalid_phone_number')
+ end
+end
diff --git a/app/errors/unicode_support_error.rb b/app/errors/unicode_support_error.rb
new file mode 100644
index 000000000..ff84126e6
--- /dev/null
+++ b/app/errors/unicode_support_error.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class UnicodeSupportError < StandardError
+ def message
+ I18n.t('errors.unicode_not_supported')
+ end
+end
diff --git a/app/services/academic_calendars/duplicate_calendar_service.rb b/app/services/academic_calendars/duplicate_calendar_service.rb
new file mode 100644
index 000000000..57b8f2dfc
--- /dev/null
+++ b/app/services/academic_calendars/duplicate_calendar_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module AcademicCalendars
+ class DuplicateCalendarService
+ attr_reader :record, :prefixed_param
+
+ def initialize(record, prefixed_param)
+ @record = record
+ @prefixed_param = prefixed_param
+ end
+
+ def duplicate
+ clone_record = @record.dup
+ clone_record.send(@prefixed_param).prepend('[Kopyası] ')
+ clone_record.committee_decisions << @record.committee_decisions
+ clone_record.save
+ clone_record
+ end
+ end
+end
diff --git a/app/services/duplicate_service.rb b/app/services/duplicate_service.rb
deleted file mode 100644
index 768ca4aec..000000000
--- a/app/services/duplicate_service.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-class DuplicateService
- attr_reader :record, :prefixed_param
-
- def initialize(record, prefixed_param)
- @record = record
- @prefixed_param = prefixed_param
- end
-
- def duplicate
- clone_record = @record.dup
- clone_record.send(@prefixed_param).prepend('[Kopyası] ')
- clone_record.save
- clone_record
- end
-end
diff --git a/app/services/nexmo/error_handler.rb b/app/services/nexmo/error_handler.rb
new file mode 100644
index 000000000..0f5466091
--- /dev/null
+++ b/app/services/nexmo/error_handler.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Nexmo
+ module ErrorHandler
+ SOFT_FAIL_CODES = {
+ '2' => 'Missing Parameters',
+ '3' => 'Invalid Parameters',
+ '6' => 'Invalid Message',
+ '12' => 'Message Too Long',
+ '22' => 'Invalid Network Code',
+ '33' => 'Number De-activated'
+ }.freeze
+
+ HARD_FAIL_CODES = {
+ '1' => 'Throttled',
+ '4' => 'Invalid Credentials',
+ '5' => 'Internal Error',
+ '7' => 'Number Barred',
+ '8' => 'Partner Account Barred',
+ '9' => 'Partner Quota Violation',
+ '10' => 'Too Many Existing Binds',
+ '11' => 'Account Not Enabled For HTTP',
+ '14' => 'Invalid Signature',
+ '15' => 'Invalid Sender Address',
+ '23' => 'Invalid Callback Url',
+ '32' => 'Signature And API Secret Disallowed'
+ }.freeze
+
+ def log_or_notify_admin(response)
+ status = response.status
+ notifier = Slack::Notifier.new Tenant.credentials.slack[:panik_hook]
+
+ if status == '0'
+ Rails.logger.info "Sent message id=#{response.message_id}"
+ elsif SOFT_FAIL_CODES.key?(status) || HARD_FAIL_CODES.key?(status)
+ notifier.ping "Nexmo SMS Error (code: #{status}, text: #{response.error_text})"
+ end
+ end
+ end
+end
diff --git a/app/services/nexmo/sms.rb b/app/services/nexmo/sms.rb
new file mode 100644
index 000000000..e9ea0e1e0
--- /dev/null
+++ b/app/services/nexmo/sms.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require_relative 'error_handler'
+
+module Nexmo
+ class Sms
+ include ErrorHandler
+
+ # Some countries and operators do not support concatenation and unicode.
+ # Enable multipart and unicode type thoughtfully!
+ # Nexmo supports all the standard GSM characters + characters from the GSM extended table.
+ # Test GSM-7 encoding: http://chadselph.github.io/smssplit/
+ def initialize(to_number, message, country = 'tr', multipart = false, type = 'text')
+ @to_number = to_number.to_s
+ @message = type.eql?('text') ? message.asciified.squish : message
+ @country = country
+ @multipart = multipart
+ @type = type
+
+ process_message
+ end
+
+ private
+
+ def process_message
+ check_country
+ check_destination_number
+ check_multipart
+ check_encoding
+ send_message
+ end
+
+ def check_country
+ @country = Country.find_by(alpha_2_code: @country.upcase)
+
+ raise ActiveRecord::RecordNotFound, 'Country can not be found!' unless @country
+ end
+
+ def check_destination_number
+ valid_number = TelephoneNumber.valid?(@to_number, @country.alpha_2_code.to_sym, [:mobile])
+
+ raise InvalidPhoneNumberError unless valid_number
+
+ parser = TelephoneNumber.parse(@to_number, @country.alpha_2_code.to_sym)
+ @to_number = parser.country.country_code + parser.formatter.normalized_number
+ end
+
+ def check_multipart
+ encoding = SmsTools::EncodingDetection.new(@message)
+
+ raise ConcatenationError if @multipart.eql?(false) && encoding.concatenated?
+ raise ConcatenationError if @multipart.eql?(false) && !@country.sms_concatenation
+ end
+
+ def check_encoding
+ encoding = SmsTools::EncodingDetection.new(@message).encoding.to_s
+ encoding = 'text' if encoding.eql?('gsm') || encoding.eql?('ascii')
+
+ raise EncodingMismatchError unless encoding == @type
+ raise UnicodeSupportError if encoding.eql?('unicode') && !@country.sms_unicode
+ end
+
+ def send_message
+ response = NEXMO_CLIENT.sms.send(
+ from: Tenant.credentials.dig(:nexmo, :from),
+ to: @to_number,
+ type: @type,
+ text: @message
+ ).messages.first
+
+ log_or_notify_admin(response)
+ end
+ end
+end
diff --git a/app/services/twilio/lookup.rb b/app/services/twilio/lookup.rb
new file mode 100644
index 000000000..22270784e
--- /dev/null
+++ b/app/services/twilio/lookup.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# See https://www.twilio.com/docs/lookup/api for options
+
+module Twilio
+ class Lookup
+ def initialize(number, **options)
+ @response = TWILIO_CLIENT.lookups.phone_numbers(number.to_s).fetch(options)
+ end
+ end
+end
diff --git a/app/services/twilio/sms.rb b/app/services/twilio/sms.rb
new file mode 100644
index 000000000..267d31312
--- /dev/null
+++ b/app/services/twilio/sms.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Twilio
+ class SMS
+ def initialize(to, body)
+ response = TWILIO_CLIENT.messages.create(
+ from: Tenant.credentials.twilio[:sender],
+ to: to,
+ body: body
+ )
+
+ response.sid
+ end
+ end
+end
diff --git a/app/services/twilio/verify.rb b/app/services/twilio/verify.rb
new file mode 100644
index 000000000..cdfbb7034
--- /dev/null
+++ b/app/services/twilio/verify.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+# See https://www.twilio.com/verify and authy documentation for details.
+
+module Twilio
+ class Verify
+ # register user to authy!
+ def register_user(email, phone, country_code)
+ authy = Authy::API.register_user(
+ email: email,
+ cellphone: phone,
+ country_code: country_code
+ )
+
+ if authy.ok?
+ # store authy_id in database
+ user.authy_id = authy.id
+ else
+ authy.errors
+ end
+ end
+
+ # verify authy token
+ def verify_token(user, token)
+ response = Authy::API.verify(
+ id: user.authy_id,
+ token: token
+ )
+
+ response.ok? ? 'ok' : 'invalid'
+ end
+
+ # request authy code as SMS
+ def request_sms(user)
+ response = Authy::API.request_sms(
+ id: user.authy_id,
+ locale: user.preferred_language || 'tr'
+ )
+
+ response.ok? ? 'ok' : response.errors
+ end
+
+ # request QR code for Google Authenticator etc.
+ def request_qr_code(user)
+ response = Authy::API.request_qr_code(
+ id: user.authy_id,
+ qr_size: 500,
+ label: Tenant.configuration.name
+ )
+
+ if response.ok?
+ response.qr_code # link of the QR image
+ else
+ response.errors
+ end
+ end
+
+ def send_phone_verification_code(phone_number, locale = 'tr')
+ response = Authy::PhoneVerification.start(
+ via: 'sms',
+ country_code: parse_phone_number(phone_number)[:country_code],
+ phone_number: parse_phone_number(phone_number)[:normalized_number],
+ locale: locale
+ )
+
+ 'ok' if response.ok?
+ end
+
+ def check_verification_code(phone_number, verification_code)
+ response = Authy::PhoneVerification.check(
+ verification_code: verification_code,
+ country_code: parse_phone_number(phone_number)[:country_code],
+ phone_number: parse_phone_number(phone_number)[:normalized_number]
+ )
+
+ 'ok' if response.ok?
+ end
+
+ private
+
+ def parse_phone_number(phone_number)
+ parsed_number = TelephoneNumber.parse(phone_number)
+
+ {
+ country_code: parsed_number.country.country_code,
+ normalized_number: parsed_number.normalized_number
+ }
+ end
+ end
+end
diff --git a/app/views/committee/agenda_types/index.html.erb b/app/views/committee/agenda_types/index.html.erb
index bb685a2ab..da1efa3da 100644
--- a/app/views/committee/agenda_types/index.html.erb
+++ b/app/views/committee/agenda_types/index.html.erb
@@ -1,12 +1,11 @@
-
- <%= link_to_new t('.new_agenda_type_link'), new_agenda_type_path %>
-
-
<%= render 'layouts/shared/smart_search_form',
diff --git a/app/views/course_management/available_courses/index.html.erb b/app/views/course_management/available_courses/index.html.erb
index c2d7c920f..cb586fda1 100644
--- a/app/views/course_management/available_courses/index.html.erb
+++ b/app/views/course_management/available_courses/index.html.erb
@@ -1,5 +1,3 @@
-<%= render 'search' %>
-
@@ -10,6 +8,7 @@
+ <%= render 'search' %>
@@ -26,7 +25,7 @@
<% @available_courses.each do |available_course| %>
- <%= available_course.unit.name %> |
+ <%= available_course.unit.names_depth_cache %> |
<%= full_name(available_course.academic_term) %> |
<%= available_course.curriculum.name %> |
<%= available_course.course.code %> |
diff --git a/app/views/course_management/course_group_types/index.html.erb b/app/views/course_management/course_group_types/index.html.erb
index 8abc975ef..afb68e58a 100644
--- a/app/views/course_management/course_group_types/index.html.erb
+++ b/app/views/course_management/course_group_types/index.html.erb
@@ -1,12 +1,11 @@
-
- <%= link_to_new t('.new_course_group_type_link'), new_course_group_type_path %>
-
-
<%= render 'layouts/shared/smart_search_form',
diff --git a/app/views/course_management/course_groups/index.html.erb b/app/views/course_management/course_groups/index.html.erb
index 70e0be59f..d3f35c35c 100644
--- a/app/views/course_management/course_groups/index.html.erb
+++ b/app/views/course_management/course_groups/index.html.erb
@@ -1,16 +1,14 @@
-
- <%= link_to_new t('.new_course_group_link'), new_course_group_path %>
-
-
-<%= render 'search' %>
-
+ <%= render 'search' %>
@@ -26,7 +24,7 @@
<%= course_group.name %> |
<%= course_group.total_ects_condition %> |
- <%= course_group.unit.try(:name) %> |
+ <%= course_group.unit.try(:names_depth_cache) %> |
<%= course_group.course_group_type.try(:name) %> |
<%= link_to_actions(course_group) %> |
diff --git a/app/views/course_management/course_types/index.html.erb b/app/views/course_management/course_types/index.html.erb
index 90583d2ae..476eab3f1 100644
--- a/app/views/course_management/course_types/index.html.erb
+++ b/app/views/course_management/course_types/index.html.erb
@@ -1,12 +1,11 @@
-
- <%= link_to_new t('.new_course_type_link'), new_course_type_path %>
-
-
<%= render 'layouts/shared/smart_search_form',
diff --git a/app/views/course_management/courses/index.html.erb b/app/views/course_management/courses/index.html.erb
index 988de8e6d..9edf45c39 100644
--- a/app/views/course_management/courses/index.html.erb
+++ b/app/views/course_management/courses/index.html.erb
@@ -1,18 +1,16 @@
-
- <%= link_to_new t('.add_new_course'), new_course_path %>
-
-
-<%= render 'search' %>
-