Merge branch 'master' into disable_rfcs
# Conflicts: # app/assets/stylesheets/editor.css.scss
This commit is contained in:
20
.travis.yml
20
.travis.yml
@ -12,14 +12,11 @@ addons:
|
||||
before_install:
|
||||
- export DISPLAY=:99.0
|
||||
- sh -e /etc/init.d/xvfb start
|
||||
# Config to run docker tests - doesn't work so far
|
||||
# - sudo apt-get update
|
||||
# - sudo apt-get upgrade lxc-docker
|
||||
# - echo 'DOCKER_OPTS="-H tcp://127.0.0.1:4243 -H unix:///var/run/docker.sock --iptables=false"' | sudo tee /etc/default/docker > /dev/null
|
||||
# - export DOCKER_HOST=tcp://192.168.23.75:2375
|
||||
# - sudo service docker restart
|
||||
# - sleep 5
|
||||
# - docker pull openhpi/docker_ruby
|
||||
- echo 'DOCKER_OPTS="-H tcp://127.0.0.1:2376 -H unix:///var/run/docker.sock --iptables=false"' | sudo tee /etc/default/docker > /dev/null
|
||||
- sudo service docker restart
|
||||
- sleep 5
|
||||
- docker pull openhpi/co_execenv_python
|
||||
- docker pull openhpi/co_execenv_java
|
||||
|
||||
before_script:
|
||||
- cp .rspec.travis .rspec
|
||||
@ -27,6 +24,7 @@ before_script:
|
||||
- cp config/code_ocean.yml.travis config/code_ocean.yml
|
||||
- cp config/database.yml.travis config/database.yml
|
||||
- cp config/secrets.yml.travis config/secrets.yml
|
||||
- cp config/docker.yml.erb.travis config/docker.yml.erb
|
||||
- psql --command='CREATE DATABASE travis_ci_test;' --username=postgres
|
||||
- bundle exec rake db:schema:load RAILS_ENV=test
|
||||
|
||||
@ -35,8 +33,4 @@ language: ruby
|
||||
rvm:
|
||||
- 2.3.6
|
||||
|
||||
script: bundle exec rspec --color --format documentation --require spec_helper --require rails_helper --tag ~docker && bundle exec codeclimate-test-reporter
|
||||
# one of the solutions I've found
|
||||
# - sudo docker run --rm=true -v `pwd`:/ansible-apache:rw weldpua2008/docker-ansible:${OS_TYPE}${OS_VERSION}_v${ANSIBLE_VERSION} /bin/bash -c "/ansible-apache/tests/test-in-docker-image.sh ${OS_TYPE} ${OS_VERSION} ${ANSIBLE_VERSION}"
|
||||
|
||||
|
||||
script: bundle exec rspec --color --format documentation --require spec_helper --require rails_helper && bundle exec codeclimate-test-reporter
|
||||
|
1
Gemfile
1
Gemfile
@ -7,6 +7,7 @@ gem 'carrierwave'
|
||||
gem 'coffee-rails'
|
||||
gem 'concurrent-ruby'
|
||||
gem 'concurrent-ruby-ext', platform: :ruby
|
||||
gem 'activerecord-deprecated_finders', require: 'active_record/deprecated_finders'
|
||||
gem 'docker-api', require: 'docker'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'forgery'
|
||||
|
@ -31,6 +31,7 @@ GEM
|
||||
activemodel (= 4.2.10)
|
||||
activesupport (= 4.2.10)
|
||||
arel (~> 6.0)
|
||||
activerecord-deprecated_finders (1.0.4)
|
||||
activerecord-jdbc-adapter (50.0)
|
||||
activerecord (>= 2.2)
|
||||
activerecord-jdbcpostgresql-adapter (50.0)
|
||||
@ -388,6 +389,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
activerecord-deprecated_finders
|
||||
activerecord-jdbcpostgresql-adapter
|
||||
autotest-rails
|
||||
bcrypt
|
||||
|
@ -42,17 +42,14 @@ button i.fa-spin {
|
||||
background-color: #008CBA;
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.button-two-only, .btn-group-two-only {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
button, .btn-group {
|
||||
width: 50%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
@ -195,4 +192,4 @@ button i.fa-spin {
|
||||
|
||||
.enforce-bottom-margin {
|
||||
margin-bottom: 5px !important;
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,8 @@
|
||||
}
|
||||
|
||||
.chosen-container {
|
||||
width: 250px !important;
|
||||
min-width: 250px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.code-field {
|
||||
|
@ -14,46 +14,40 @@ class RequestForCommentsController < ApplicationController
|
||||
def index
|
||||
@search = RequestForComment
|
||||
.last_per_user(2)
|
||||
.joins('join "submissions" s on s.id = request_for_comments.submission_id
|
||||
left outer join "files" f on f.context_id = s.id
|
||||
left outer join "comments" on comments.file_id = f.id')
|
||||
.group('request_for_comments.id, request_for_comments.user_id, request_for_comments.exercise_id,
|
||||
request_for_comments.file_id, request_for_comments.question, request_for_comments.created_at,
|
||||
request_for_comments.updated_at, request_for_comments.user_type, request_for_comments.solved,
|
||||
request_for_comments.full_score_reached, request_for_comments.submission_id, request_for_comments.row_number') # ugly, but rails wants it this way
|
||||
.select('request_for_comments.*, max(comments.updated_at) as last_comment')
|
||||
.with_last_activity
|
||||
.search(params[:q])
|
||||
@request_for_comments = @search.result.order('created_at DESC').paginate(page: params[:page], total_entries: @search.result.length)
|
||||
@request_for_comments = @search.result
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page], total_entries: @search.result.length)
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /my_request_for_comments
|
||||
def get_my_comment_requests
|
||||
@search = RequestForComment
|
||||
.with_last_activity
|
||||
.where(user_id: current_user.id)
|
||||
.joins('join "submissions" s on s.id = request_for_comments.submission_id
|
||||
left outer join "files" f on f.context_id = s.id
|
||||
left outer join "comments" on comments.file_id = f.id')
|
||||
.group('request_for_comments.id')
|
||||
.select('request_for_comments.*, max(comments.updated_at) as last_comment')
|
||||
.search(params[:q])
|
||||
@request_for_comments = @search.result.order('created_at DESC').paginate(page: params[:page])
|
||||
@request_for_comments = @search.result
|
||||
.order('created_at DESC')
|
||||
.paginate(page: params[:page])
|
||||
render 'index'
|
||||
end
|
||||
|
||||
# GET /my_rfc_activity
|
||||
def get_rfcs_with_my_comments
|
||||
@search = RequestForComment
|
||||
.with_last_activity
|
||||
.joins(:comments) # we don't need to outer join here, because we know the user has commented on these
|
||||
.where(comments: {user_id: current_user.id})
|
||||
.joins('join "submissions" s on s.id = request_for_comments.submission_id
|
||||
left outer join "files" f on f.context_id = s.id
|
||||
left outer join "comments" as c on c.file_id = f.id')
|
||||
.group('request_for_comments.id')
|
||||
.select('request_for_comments.*, max(c.updated_at) as last_comment')
|
||||
.search(params[:q])
|
||||
@request_for_comments = @search.result.order('last_comment DESC').paginate(page: params[:page])
|
||||
@request_for_comments = @search.result
|
||||
.order('last_comment DESC')
|
||||
.paginate(page: params[:page])
|
||||
render 'index'
|
||||
end
|
||||
|
||||
# GET /request_for_comments/1/mark_as_solved
|
||||
def mark_as_solved
|
||||
authorize!
|
||||
@request_for_comment.solved = true
|
||||
@ -66,6 +60,7 @@ class RequestForCommentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# POST /request_for_comments/1/set_thank_you_note
|
||||
def set_thank_you_note
|
||||
authorize!
|
||||
@request_for_comment.thank_you_note = params[:note]
|
||||
@ -82,10 +77,6 @@ class RequestForCommentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def submit
|
||||
|
||||
end
|
||||
|
||||
# GET /request_for_comments/1
|
||||
# GET /request_for_comments/1.json
|
||||
def show
|
||||
@ -146,10 +137,6 @@ class RequestForCommentsController < ApplicationController
|
||||
authorize!
|
||||
end
|
||||
|
||||
def comment_params
|
||||
params.permit(:exercise_id, :feedback_text).merge(user_id: current_user.id, user_type: current_user.class.name)
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_request_for_comment
|
||||
@ -162,4 +149,8 @@ class RequestForCommentsController < ApplicationController
|
||||
params.require(:request_for_comment).permit(:exercise_id, :file_id, :question, :requested_at, :solved, :submission_id).merge(user_id: current_user.id, user_type: current_user.class.name)
|
||||
end
|
||||
|
||||
def comment_params
|
||||
params.permit(:exercise_id, :feedback_text).merge(user_id: current_user.id, user_type: current_user.class.name)
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -75,6 +75,11 @@ class SubmissionsController < ApplicationController
|
||||
zio.write(file.content)
|
||||
end
|
||||
|
||||
# zip exercise description
|
||||
zio.put_next_entry(t('activerecord.models.exercise.one') + '.txt')
|
||||
zio.write(@submission.exercise.title + "\r\n======================\r\n")
|
||||
zio.write(@submission.exercise.description)
|
||||
|
||||
# zip .co file
|
||||
zio.put_next_entry(".co")
|
||||
zio.write(File.read id_file)
|
||||
@ -167,7 +172,7 @@ class SubmissionsController < ApplicationController
|
||||
# if the command is 'client_kill', send it to docker otherwise.
|
||||
begin
|
||||
parsed = JSON.parse(data)
|
||||
if parsed['cmd'] == 'client_kill'
|
||||
if parsed.class == Hash && parsed['cmd'] == 'client_kill'
|
||||
Rails.logger.debug("Client exited container.")
|
||||
@docker_client.kill_container(result[:container])
|
||||
else
|
||||
|
@ -47,7 +47,7 @@ class ExecutionEnvironment < ActiveRecord::Base
|
||||
private :validate_docker_image?
|
||||
|
||||
def working_docker_image?
|
||||
DockerClient.pull(docker_image) unless DockerClient.image_tags.include?(docker_image)
|
||||
DockerClient.pull(docker_image) unless DockerClient.find_image_by_tag(docker_image).blank?
|
||||
output = DockerClient.new(execution_environment: self).execute_arbitrary_command(VALIDATION_COMMAND)
|
||||
errors.add(:docker_image, "error: #{output[:stderr]}") if output[:stderr].present?
|
||||
rescue DockerClient::Error => error
|
||||
|
@ -11,7 +11,13 @@ class RequestForComment < ActiveRecord::Base
|
||||
scope :not_stale, -> { where("user_id%10 <2 OR user_id%10 >= 4").where(exercise.exercise_collections.none{|ec| ec.id = 3} } ########### todo
|
||||
|
||||
def self.last_per_user(n = 5)
|
||||
from("(#{row_number_user_sql}) as request_for_comments").where("row_number <= ?", n)
|
||||
from("(#{row_number_user_sql}) as request_for_comments")
|
||||
.where("row_number <= ?", n)
|
||||
.group('request_for_comments.id, request_for_comments.user_id, request_for_comments.exercise_id,
|
||||
request_for_comments.file_id, request_for_comments.question, request_for_comments.created_at,
|
||||
request_for_comments.updated_at, request_for_comments.user_type, request_for_comments.solved,
|
||||
request_for_comments.full_score_reached, request_for_comments.submission_id, request_for_comments.row_number')
|
||||
# ugly, but necessary
|
||||
end
|
||||
|
||||
# not used right now, finds the last submission for the respective user and exercise.
|
||||
@ -47,6 +53,14 @@ class RequestForComment < ActiveRecord::Base
|
||||
commenters.uniq {|user| user.id}
|
||||
end
|
||||
|
||||
def self.with_last_activity
|
||||
self.joins('join "submissions" s on s.id = request_for_comments.submission_id
|
||||
left outer join "files" f on f.context_id = s.id
|
||||
left outer join "comments" c on c.file_id = f.id')
|
||||
.group('request_for_comments.id')
|
||||
.select('request_for_comments.*, max(c.updated_at) as last_comment')
|
||||
end
|
||||
|
||||
def to_s
|
||||
"RFC-" + self.id.to_s
|
||||
end
|
||||
|
@ -1,5 +1,6 @@
|
||||
#flash.fixed_error_messages data-message-failure=t('shared.message_failure')
|
||||
- %w[alert danger info notice success warning].each do |severity|
|
||||
div.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}"
|
||||
p id="flash-#{severity}" = flash[severity]
|
||||
span.fa.fa-times
|
||||
#flash-container
|
||||
#flash.container.fixed_error_messages data-message-failure=t('shared.message_failure')
|
||||
- %w[alert danger info notice success warning].each do |severity|
|
||||
div.alert.flash class="alert-#{{'alert' => 'warning', 'notice' => 'success'}.fetch(severity, severity)}"
|
||||
p id="flash-#{severity}" = flash[severity]
|
||||
span.fa.fa-times
|
||||
|
@ -32,8 +32,8 @@ html lang='en'
|
||||
li = link_to(t('shared.help.link'), '#modal-help', data: {toggle: 'modal'})
|
||||
= render('session')
|
||||
.container data-controller=controller_name
|
||||
= render('breadcrumbs')
|
||||
= render('flash')
|
||||
= render('breadcrumbs')
|
||||
- if (controller_name == "exercises" && action_name == "implement")
|
||||
.container-fluid
|
||||
= yield
|
||||
|
@ -10,7 +10,7 @@ h2 = t('shared.statistics')
|
||||
= row(label: '.score') do
|
||||
p == t('shared.out_of', maximum_value: @submission.exercise.maximum_score, value: @submission.score)
|
||||
p = progress_bar(@submission.percentage)
|
||||
= row(label: '.final_submissions', value: @submission.exercise.submissions.final.distinct.count(:user_id, :user_type) - 1)
|
||||
/= row(label: '.final_submissions', value: @submission.exercise.submissions.final.distinct.count(:user_id, :user_type) - 1)
|
||||
/= row(label: '.average_score') do
|
||||
/ p == t('shared.out_of', maximum_value: @submission.exercise.maximum_score, value: @submission.exercise.average_score.round(2))
|
||||
/ p = progress_bar(@submission.exercise.average_percentage)
|
||||
|
@ -34,7 +34,21 @@ production:
|
||||
ws_host: ws://localhost:4243 #url to connect rails server to docker host
|
||||
ws_client_protocol: wss:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production)
|
||||
|
||||
staging:
|
||||
<<: *default
|
||||
host: unix:///var/run/docker.sock
|
||||
pool:
|
||||
active: true
|
||||
refill:
|
||||
async: false
|
||||
batch_size: 8
|
||||
interval: 15
|
||||
timeout: 60
|
||||
workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %>
|
||||
ws_host: ws://localhost:4243 #url to connect rails server to docker host
|
||||
ws_client_protocol: 'wss:' #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production)
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
host: tcp://192.168.59.104:2376
|
||||
host: tcp://127.0.0.1:2376
|
||||
workspace_root: <%= File.join('/', 'shared', Rails.env) %>
|
||||
|
@ -32,7 +32,7 @@ production:
|
||||
timeout: 60
|
||||
workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %>
|
||||
ws_host: ws://localhost:4243 #url to connect rails server to docker host
|
||||
ws_client_protocol: 'wss:' #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production)
|
||||
ws_client_protocol: wss:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production)
|
||||
|
||||
staging:
|
||||
<<: *default
|
||||
@ -50,5 +50,5 @@ staging:
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
host: tcp://192.168.59.104:2376
|
||||
host: tcp://127.0.0.1:2376
|
||||
workspace_root: <%= File.join('/', 'shared', Rails.env) %>
|
@ -3,3 +3,31 @@ class AddSubmissionToRequestForComments < ActiveRecord::Migration
|
||||
add_reference :request_for_comments, :submission
|
||||
end
|
||||
end
|
||||
|
||||
=begin
|
||||
We issued the following on the database to add the submission_ids for existing entries
|
||||
|
||||
UPDATE request_for_comments
|
||||
SET submission_id = sub.submission_id_external
|
||||
FROM
|
||||
(SELECT s.id AS submission_id_external,
|
||||
rfc.id AS rfc_id,
|
||||
s.created_at AS submission_created_at,
|
||||
rfc.created_at AS rfc_created_at
|
||||
FROM submissions s,
|
||||
request_for_comments rfc
|
||||
WHERE s.user_id = rfc.user_id
|
||||
AND s.exercise_id = rfc.exercise_id
|
||||
AND rfc.created_at + interval '2 hours' > s.created_at
|
||||
AND s.created_at =
|
||||
(SELECT MAX(created_at)
|
||||
FROM submissions
|
||||
WHERE exercise_id = s.exercise_id
|
||||
AND user_id = s.user_id
|
||||
AND rfc.created_at + interval '2 hours' > created_at
|
||||
GROUP BY s.exercise_id,
|
||||
s.user_id)) as sub
|
||||
WHERE id = sub.rfc_id
|
||||
AND submission_id IS NULL;
|
||||
|
||||
=end
|
||||
|
@ -1,8 +1,8 @@
|
||||
class AddReachedFullScoreToRequestForComment < ActiveRecord::Migration
|
||||
def up
|
||||
add_column :request_for_comments, :full_score_reached, :boolean, default: false
|
||||
RequestForComment.all.each { |rfc|
|
||||
if (rfc.submission.present? && rfc.submission.exercise.has_user_solved(rfc.user))
|
||||
RequestForComment.find_each { |rfc|
|
||||
if rfc.submission.present? and rfc.submission.exercise.has_user_solved(rfc.user)
|
||||
rfc.full_score_reached = true
|
||||
rfc.save
|
||||
end
|
||||
|
@ -1,6 +1,10 @@
|
||||
class FixTimestampsOnFeedback < ActiveRecord::Migration
|
||||
def change
|
||||
def up
|
||||
change_column_default(:user_exercise_feedbacks, :created_at, nil)
|
||||
change_column_default(:user_exercise_feedbacks, :updated_at, nil)
|
||||
end
|
||||
|
||||
def down
|
||||
|
||||
end
|
||||
end
|
||||
|
@ -1,3 +1,8 @@
|
||||
#flash-container {
|
||||
position: relative;
|
||||
top: -21px;
|
||||
}
|
||||
|
||||
.flash {
|
||||
display: none;
|
||||
|
||||
@ -10,14 +15,10 @@
|
||||
}
|
||||
|
||||
.fixed_error_messages {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
padding: inherit;
|
||||
width: 100%;
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 140 KiB |
@ -98,7 +98,7 @@ describe Lti do
|
||||
let(:consumer) { FactoryBot.create(:consumer) }
|
||||
let(:score) { 0.5 }
|
||||
let(:submission) { FactoryBot.create(:submission) }
|
||||
let!(:lti_parameter) { FactoryBot.create(:lti_parameter)}
|
||||
let!(:lti_parameter) { FactoryBot.create(:lti_parameter, consumers_id: consumer.id, external_users_id: submission.user_id, exercises_id: submission.exercise_id)}
|
||||
|
||||
context 'with an invalid score' do
|
||||
it 'raises an exception' do
|
||||
@ -114,7 +114,6 @@ describe Lti do
|
||||
|
||||
context 'when grading is not supported' do
|
||||
it 'returns a corresponding status' do
|
||||
skip('ralf: this does not work, since send_score pulls data from the database, which then returns an empty array. On this is called .first, which returns nil and lets the test fail. Before Toms changes, this was taken from the session, which could be mocked')
|
||||
expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(false)
|
||||
expect(controller.send(:send_score, submission.exercise_id, score, submission.user_id)[:status]).to eq('unsupported')
|
||||
end
|
||||
@ -133,12 +132,10 @@ describe Lti do
|
||||
end
|
||||
|
||||
it 'sends the score' do
|
||||
skip('ralf: this does not work, since send_score pulls data from the database, which then returns an empty array. On this is called .first, which returns nil and lets the test fail. Before Toms changes, this was taken from the session, which could be mocked')
|
||||
controller.send(:send_score, submission.exercise_id, score, submission.user_id)
|
||||
end
|
||||
|
||||
it 'returns code, message, and status' do
|
||||
skip('ralf: this does not work, since send_score pulls data from the database, which then returns an empty array. On this is called .first, which returns nil and lets the test fail. Before Toms changes, this was taken from the session, which could be mocked')
|
||||
result = controller.send(:send_score, submission.exercise_id, score, submission.user_id)
|
||||
expect(result[:code]).to eq(response.response_code)
|
||||
expect(result[:message]).to eq(response.body)
|
||||
|
@ -2,6 +2,7 @@ FactoryBot.define do
|
||||
factory :user_exercise_feedback, class: UserExerciseFeedback do
|
||||
created_by_external_user
|
||||
feedback_text 'Most suitable exercise ever'
|
||||
association :exercise, factory: :math
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -2,12 +2,22 @@ require 'rails_helper'
|
||||
require 'seeds_helper'
|
||||
|
||||
describe DockerClient, docker: true do
|
||||
WORKSPACE_PATH = '/tmp/code_ocean_test'
|
||||
|
||||
let(:command) { 'whoami' }
|
||||
let(:docker_client) { described_class.new(execution_environment: FactoryBot.build(:java), user: FactoryBot.build(:admin)) }
|
||||
let(:execution_environment) { FactoryBot.build(:java) }
|
||||
let(:image) { double }
|
||||
let(:submission) { FactoryBot.create(:submission) }
|
||||
let(:workspace_path) { '/tmp' }
|
||||
let(:workspace_path) { WORKSPACE_PATH }
|
||||
|
||||
before(:all) do
|
||||
FileUtils.mkdir_p(WORKSPACE_PATH)
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
FileUtils.rm_rf(WORKSPACE_PATH)
|
||||
end
|
||||
|
||||
describe '.check_availability!' do
|
||||
context 'when a socket error occurs' do
|
||||
@ -129,7 +139,7 @@ describe DockerClient, docker: true do
|
||||
after(:each) { docker_client.send(:create_workspace_files, container, submission) }
|
||||
|
||||
it 'creates submission-specific directories' do
|
||||
expect(Dir).to receive(:mkdir).at_least(:once)
|
||||
expect(Dir).to receive(:mkdir).at_least(:once).and_call_original
|
||||
end
|
||||
|
||||
it 'copies binary files' do
|
||||
|
@ -1,6 +1,5 @@
|
||||
FactoryBot.define do
|
||||
factory :error_template_attribute do
|
||||
error_template nil
|
||||
key "MyString"
|
||||
regex "MyString"
|
||||
end
|
||||
|
Reference in New Issue
Block a user