mirror of
https://github.com/internetee/registry.git
synced 2025-07-24 19:48:28 +02:00
commit
9565f23e68
50 changed files with 2463 additions and 323 deletions
|
@ -2,6 +2,7 @@ module Admin
|
|||
class ContactsController < BaseController
|
||||
load_and_authorize_resource
|
||||
before_action :set_contact, only: [:show]
|
||||
helper_method :ident_types
|
||||
|
||||
def index
|
||||
params[:q] ||= {}
|
||||
|
@ -79,5 +80,9 @@ module Admin
|
|||
|
||||
params[:q][:created_at_lteq] = ca_cache
|
||||
end
|
||||
|
||||
def ident_types
|
||||
Contact::Ident.types
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -152,9 +152,6 @@ class EppController < ApplicationController
|
|||
code: '1',
|
||||
msg: 'handle_errors was executed when there were actually no errors'
|
||||
}
|
||||
# rubocop:disable Rails/Output
|
||||
puts "FULL MESSAGE: #{obj.errors.full_messages} #{obj.errors.inspect}" if Rails.env.test?
|
||||
# rubocop: enable Rails/Output
|
||||
end
|
||||
|
||||
@errors.uniq!
|
||||
|
|
|
@ -2,6 +2,7 @@ class Registrar
|
|||
class ContactsController < DeppController
|
||||
before_action :init_epp_contact
|
||||
helper_method :address_processing?
|
||||
helper_method :ident_types
|
||||
|
||||
def index
|
||||
authorize! :view, Depp::Contact
|
||||
|
@ -140,5 +141,9 @@ class Registrar
|
|||
def address_processing?
|
||||
Contact.address_processing?
|
||||
end
|
||||
|
||||
def ident_types
|
||||
Contact::Ident.types
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,6 +11,12 @@ module EppErrors
|
|||
epp_errors << collect_child_errors(attr)
|
||||
end
|
||||
|
||||
if self.class.reflect_on_aggregation(attr)
|
||||
aggregation = send(attr)
|
||||
epp_errors << collect_aggregation_errors(aggregation)
|
||||
next
|
||||
end
|
||||
|
||||
epp_errors << collect_parent_errors(attr, errors)
|
||||
end
|
||||
|
||||
|
@ -46,6 +52,31 @@ module EppErrors
|
|||
epp_errors
|
||||
end
|
||||
|
||||
def collect_aggregation_errors(aggregation)
|
||||
epp_errors = []
|
||||
|
||||
aggregation.errors.details.each do |attr, error_details|
|
||||
error_details.each do |error_detail|
|
||||
aggregation.class.epp_code_map.each do |epp_code, attr_to_error|
|
||||
epp_code_found = attr_to_error.any? { |i| i == [attr, error_detail[:error]] }
|
||||
|
||||
next unless epp_code_found
|
||||
|
||||
message = aggregation.errors.generate_message(attr, error_detail[:error], error_detail)
|
||||
message = aggregation.errors.full_message(attr, message)
|
||||
|
||||
if attr != :base
|
||||
message = "#{aggregation.model_name.human} #{message.camelize(:lower)}"
|
||||
end
|
||||
|
||||
epp_errors << { code: epp_code, msg: message }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
epp_errors
|
||||
end
|
||||
|
||||
def find_epp_code_and_value(msg)
|
||||
epp_code_map.each do |code, values|
|
||||
values.each do |x|
|
||||
|
|
|
@ -19,27 +19,22 @@ class Contact < ActiveRecord::Base
|
|||
|
||||
accepts_nested_attributes_for :legal_documents
|
||||
|
||||
validates :name, :phone, :email, :ident, :ident_type, presence: true
|
||||
validates :name, :email, presence: true
|
||||
validates :street, :city, :zip, :country_code, presence: true, if: 'self.class.address_processing?'
|
||||
|
||||
validates :phone, format: /\+[0-9]{1,3}\.[0-9]{1,14}?/, phone: true
|
||||
validates :phone, presence: true, e164: true, phone: true
|
||||
|
||||
validates :email, format: /@/
|
||||
validates :email, email_format: { message: :invalid }, if: proc { |c| c.email_changed? }
|
||||
validates :ident,
|
||||
format: { with: /\d{4}-\d{2}-\d{2}/, message: :invalid_birthday_format },
|
||||
if: proc { |c| c.ident_type == 'birthday' }
|
||||
validates :ident_country_code, presence: true, if: proc { |c| %w(org priv).include? c.ident_type }, on: :create
|
||||
|
||||
validates :code,
|
||||
uniqueness: { message: :epp_id_taken },
|
||||
format: { with: /\A[\w\-\:\.\_]*\z/i, message: :invalid },
|
||||
length: { maximum: 100, message: :too_long_contact_code }
|
||||
validates_associated :identifier
|
||||
|
||||
validate :val_ident_type
|
||||
validate :val_ident_valid_format?
|
||||
validate :validate_html
|
||||
validate :validate_country_code
|
||||
validate :validate_ident_country_code
|
||||
|
||||
after_initialize do
|
||||
self.status_notes = {} if status_notes.nil?
|
||||
|
@ -49,8 +44,15 @@ class Contact < ActiveRecord::Base
|
|||
before_validation :to_upcase_country_code
|
||||
before_validation :strip_email
|
||||
before_create :generate_auth_info
|
||||
|
||||
before_update :manage_emails
|
||||
|
||||
composed_of :identifier,
|
||||
class_name: 'Contact::Ident',
|
||||
constructor: proc { |code, type, country_code| Contact::Ident.new(code: code,
|
||||
type: type,
|
||||
country_code: country_code) },
|
||||
mapping: [%w[ident code], %w[ident_type type], %w[ident_country_code country_code]]
|
||||
|
||||
def manage_emails
|
||||
return nil unless email_changed?
|
||||
return nil unless deliver_emails == true
|
||||
|
@ -76,12 +78,6 @@ class Contact < ActiveRecord::Base
|
|||
BIRTHDAY = 'birthday'.freeze
|
||||
PASSPORT = 'passport'
|
||||
|
||||
IDENT_TYPES = [
|
||||
ORG, # Company registry code (or similar)
|
||||
PRIV, # National idendtification number
|
||||
BIRTHDAY # Birthday date
|
||||
]
|
||||
|
||||
attr_accessor :deliver_emails
|
||||
attr_accessor :domain_transfer # hack but solves problem faster
|
||||
|
||||
|
@ -219,10 +215,6 @@ class Contact < ActiveRecord::Base
|
|||
STDOUT << "#{Time.zone.now.utc} - Successfully destroyed #{counter} orphaned contacts\n" unless Rails.env.test?
|
||||
end
|
||||
|
||||
def privs
|
||||
where("ident_type = '#{PRIV}'")
|
||||
end
|
||||
|
||||
def admin_statuses
|
||||
[
|
||||
SERVER_UPDATE_PROHIBITED,
|
||||
|
@ -299,28 +291,6 @@ class Contact < ActiveRecord::Base
|
|||
name || '[no name]'
|
||||
end
|
||||
|
||||
def val_ident_type
|
||||
errors.add(:ident_type, :epp_ident_type_invalid, code: code) if !%w(org priv birthday).include?(ident_type)
|
||||
end
|
||||
|
||||
def val_ident_valid_format?
|
||||
case ident_country_code
|
||||
when 'EE'.freeze
|
||||
err_msg = "invalid_EE_identity_format#{"_update" if id}".to_sym
|
||||
case ident_type
|
||||
when 'priv'.freeze
|
||||
errors.add(:ident, err_msg) unless Isikukood.new(ident).valid?
|
||||
when 'org'.freeze
|
||||
# !%w(1 7 8 9).freeze.include?(ident.first) ||
|
||||
if ident.size != 8 || !(ident =~/\A[0-9]{8}\z/)
|
||||
errors.add(:ident, err_msg)
|
||||
end
|
||||
when BIRTHDAY
|
||||
errors.add(:ident, err_msg) if id.blank? # only for create action right now. Later for all of them
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_html
|
||||
self.class.columns.each do |column|
|
||||
next unless column.type == :string
|
||||
|
@ -334,7 +304,6 @@ class Contact < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
|
||||
def org?
|
||||
ident_type == ORG
|
||||
end
|
||||
|
@ -344,10 +313,6 @@ class Contact < ActiveRecord::Base
|
|||
!org?
|
||||
end
|
||||
|
||||
def birthday?
|
||||
ident_type == BIRTHDAY
|
||||
end
|
||||
|
||||
def generate_auth_info
|
||||
return if @generate_auth_info_disabled
|
||||
return if auth_info.present?
|
||||
|
@ -424,10 +389,6 @@ class Contact < ActiveRecord::Base
|
|||
errors.add(:country_code, :invalid) unless Country.new(country_code)
|
||||
end
|
||||
|
||||
def validate_ident_country_code
|
||||
errors.add(:ident, :invalid_country_code) unless Country.new(ident_country_code)
|
||||
end
|
||||
|
||||
def related_domain_descriptions
|
||||
ActiveSupport::Deprecation.warn('Use #domain_names_with_roles')
|
||||
|
||||
|
@ -572,7 +533,7 @@ class Contact < ActiveRecord::Base
|
|||
return if changes.slice(*(self.class.column_names - ["updated_at", "created_at", "statuses", "status_notes"])).empty?
|
||||
|
||||
names = related_domain_descriptions.keys
|
||||
UpdateWhoisRecordJob.enqueue(names, :domain) if names.present?
|
||||
UpdateWhoisRecordJob.enqueue(names, 'domain') if names.present?
|
||||
end
|
||||
|
||||
def children_log
|
||||
|
|
74
app/models/contact/ident.rb
Normal file
74
app/models/contact/ident.rb
Normal file
|
@ -0,0 +1,74 @@
|
|||
class Contact::Ident
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :code
|
||||
attr_accessor :type
|
||||
attr_accessor :country_code
|
||||
|
||||
validates :code, presence: true
|
||||
validates :code, national_id: true, if: :national_id?
|
||||
validates :code, reg_no: true, if: :reg_no?
|
||||
validates :code, iso8601: { date_only: true }, if: :birthday?
|
||||
|
||||
validates :type, presence: true, inclusion: { in: proc { types } }
|
||||
validates :country_code, presence: true, iso31661_alpha2: true
|
||||
validates_with MismatchValidator
|
||||
|
||||
def self.epp_code_map
|
||||
{
|
||||
'2003' => [
|
||||
[:code, :blank],
|
||||
[:type, :blank],
|
||||
[:country_code, :blank]
|
||||
],
|
||||
'2005' => [
|
||||
[:base, :mismatch],
|
||||
[:code, :invalid_national_id],
|
||||
[:code, :invalid_reg_no],
|
||||
[:code, :invalid_iso8601_date],
|
||||
[:country_code, :invalid_iso31661_alpha2]
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def self.types
|
||||
%w[org priv birthday]
|
||||
end
|
||||
|
||||
def marked_for_destruction?
|
||||
false
|
||||
end
|
||||
|
||||
def birthday?
|
||||
type == 'birthday'
|
||||
end
|
||||
|
||||
def national_id?
|
||||
type == 'priv'
|
||||
end
|
||||
|
||||
def reg_no?
|
||||
type == 'org'
|
||||
end
|
||||
|
||||
def country
|
||||
Country.new(country_code)
|
||||
end
|
||||
|
||||
def ==(other_ident)
|
||||
if other_ident.is_a?(self.class)
|
||||
(code == other_ident.code) &&
|
||||
(type == other_ident.type) &&
|
||||
(country_code == other_ident.country_code)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# https://github.com/rails/rails/issues/1513
|
||||
def validation_context=(_value)
|
||||
;
|
||||
end
|
||||
end
|
|
@ -109,7 +109,7 @@ class Epp::Contact < Contact
|
|||
end
|
||||
delegate :ident_attr_valid?, to: :class
|
||||
|
||||
def epp_code_map # rubocop:disable Metrics/MethodLength
|
||||
def epp_code_map
|
||||
{
|
||||
'2003' => [ # Required parameter missing
|
||||
[:name, :blank],
|
||||
|
@ -124,31 +124,19 @@ class Epp::Contact < Contact
|
|||
[:name, :invalid],
|
||||
[:phone, :invalid],
|
||||
[:email, :invalid],
|
||||
[:ident, :invalid],
|
||||
[:ident, :invalid_EE_identity_format],
|
||||
[:ident, :invalid_EE_identity_format_update],
|
||||
[:ident, :invalid_birthday_format],
|
||||
[:ident, :invalid_country_code],
|
||||
[:country_code, :invalid],
|
||||
[:ident_type, :missing],
|
||||
[:code, :invalid],
|
||||
[:code, :too_long_contact_code]
|
||||
],
|
||||
'2302' => [ # Object exists
|
||||
[:code, :epp_id_taken]
|
||||
],
|
||||
'2304' => [ # Object status prohibits operation
|
||||
[:ident_type, :epp_ident_type_invalid, { value: { obj: 'code', val: code}, interpolation: {code: code}}]
|
||||
],
|
||||
'2305' => [ # Association exists
|
||||
[:domains, :exist]
|
||||
],
|
||||
'2306' => [ # Parameter policy error
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def update_attributes(frame, current_user)
|
||||
return super if frame.blank?
|
||||
at = {}.with_indifferent_access
|
||||
|
@ -158,9 +146,6 @@ class Epp::Contact < Contact
|
|||
at[:statuses] = statuses - statuses_attrs(frame.css('rem'), 'rem') + statuses_attrs(frame.css('add'), 'add')
|
||||
end
|
||||
|
||||
# legal_frame = frame.css('legalDocument').first
|
||||
# at[:legal_documents_attributes] = self.class.legal_document_attrs(legal_frame)
|
||||
|
||||
if doc = attach_legal_document(Epp::Domain.parse_legal_document_from_frame(frame))
|
||||
frame.css("legalDocument").first.content = doc.path if doc && doc.persisted?
|
||||
self.legal_document_id = doc.id
|
||||
|
@ -168,29 +153,28 @@ class Epp::Contact < Contact
|
|||
|
||||
self.deliver_emails = true # turn on email delivery for epp
|
||||
|
||||
ident_frame = frame.css('ident').first
|
||||
|
||||
# allow to update ident code for legacy contacts
|
||||
if frame.css('ident').first
|
||||
self.ident_updated_at ||= Time.zone.now # not in use
|
||||
ident_frame = frame.css('ident').first
|
||||
# https://github.com/internetee/registry/issues/576
|
||||
if ident_frame
|
||||
if identifier.valid?
|
||||
submitted_ident = Ident.new(code: ident_frame.text,
|
||||
type: ident_frame.attr('type'),
|
||||
country_code: ident_frame.attr('cc'))
|
||||
|
||||
if ident_frame && ident_attr_valid?(ident_frame)
|
||||
org_priv = %w(org priv).freeze
|
||||
if ident_country_code.blank? && org_priv.include?(ident_type) && org_priv.include?(ident_frame.attr('type'))
|
||||
at.merge!(ident_country_code: ident_frame.attr('cc'), ident_type: ident_frame.attr('type'))
|
||||
elsif ident_type == "birthday" && !ident[/\A\d{4}-\d{2}-\d{2}\z/] && (Date.parse(ident) rescue false)
|
||||
at.merge!(ident: ident_frame.text)
|
||||
at.merge!(ident_country_code: ident_frame.attr('cc')) if ident_frame.attr('cc').present?
|
||||
elsif ident_type == "birthday" && ident_country_code.blank?
|
||||
at.merge!(ident_country_code: ident_frame.attr('cc'))
|
||||
elsif ident_type.blank? && ident_country_code.blank?
|
||||
at.merge!(ident_type: ident_frame.attr('type'))
|
||||
at.merge!(ident_country_code: ident_frame.attr('cc')) if ident_frame.attr('cc').present?
|
||||
else
|
||||
throw :epp_error, {code: '2306', msg: I18n.t(:ident_update_error)}
|
||||
end
|
||||
report_valid_ident_error if submitted_ident != identifier
|
||||
else
|
||||
throw :epp_error, {code: '2306', msg: I18n.t(:ident_update_error)}
|
||||
ident_update_attempt = ident_frame.text.present? && (ident_frame.text != ident)
|
||||
report_ident_update_error if ident_update_attempt
|
||||
|
||||
identifier = Ident.new(code: ident,
|
||||
type: ident_frame.attr('type'),
|
||||
country_code: ident_frame.attr('cc'))
|
||||
|
||||
identifier.validate
|
||||
|
||||
self.identifier = identifier
|
||||
self.ident_updated_at ||= Time.zone.now
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -199,7 +183,6 @@ class Epp::Contact < Contact
|
|||
|
||||
super(at)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def statuses_attrs(frame, action)
|
||||
status_list = status_list_from(frame)
|
||||
|
@ -259,4 +242,13 @@ class Epp::Contact < Contact
|
|||
self.legal_document_id = doc.id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report_valid_ident_error
|
||||
throw :epp_error, { code: '2308', msg: I18n.t('epp.contacts.errors.valid_ident') }
|
||||
end
|
||||
|
||||
def report_ident_update_error
|
||||
throw :epp_error, { code: '2308', msg: I18n.t('epp.contacts.errors.ident_update') }
|
||||
end
|
||||
end
|
||||
|
|
22
app/models/epp/response.rb
Normal file
22
app/models/epp/response.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
module EPP
|
||||
class Response
|
||||
attr_accessor :results
|
||||
|
||||
def self.from_xml(xml)
|
||||
xml_doc = Nokogiri::XML(xml)
|
||||
response = new
|
||||
|
||||
result_elements = xml_doc.css('result')
|
||||
|
||||
result_elements.each do |result_element|
|
||||
response.results << Result.new(result_element[:code].to_s, result_element.text.strip)
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def initialize
|
||||
@results = []
|
||||
end
|
||||
end
|
||||
end
|
28
app/models/epp/response/result.rb
Normal file
28
app/models/epp/response/result.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
module EPP
|
||||
class Response
|
||||
class Result
|
||||
CODE_TO_TYPE = {
|
||||
'1000' => :success,
|
||||
'1001' => :success_pending,
|
||||
'1300' => :success_empty_queue,
|
||||
'1301' => :success_dequeue,
|
||||
'2001' => :syntax_error,
|
||||
'2003' => :required_param_missing,
|
||||
'2005' => :param_syntax_error,
|
||||
'2308' => :data_management_policy_violation
|
||||
}
|
||||
|
||||
attr_accessor :code
|
||||
attr_accessor :message
|
||||
|
||||
def initialize(code, message)
|
||||
@code = code
|
||||
@message = message
|
||||
end
|
||||
|
||||
def self.codes
|
||||
CODE_TO_TYPE
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,7 +9,6 @@ class Registrar < ActiveRecord::Base
|
|||
has_many :accounts
|
||||
has_many :nameservers, through: :domains
|
||||
has_many :whois_records
|
||||
has_many :priv_contacts, -> { privs }, class_name: 'Contact'
|
||||
has_many :white_ips, dependent: :destroy
|
||||
|
||||
delegate :balance, to: :cash_account, allow_nil: true
|
||||
|
|
20
app/validators/contact/ident/mismatch_validator.rb
Normal file
20
app/validators/contact/ident/mismatch_validator.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
class Contact::Ident::MismatchValidator < ActiveModel::Validator
|
||||
Mismatch = Struct.new(:type, :country)
|
||||
|
||||
def self.mismatches
|
||||
[
|
||||
Mismatch.new('birthday', Country.new('EE')),
|
||||
]
|
||||
end
|
||||
|
||||
def validate(record)
|
||||
record.errors.add(:base, :mismatch, type: record.type, country: record.country) if mismatched?(record)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mismatched?(record)
|
||||
mismatch = Mismatch.new(record.type, record.country)
|
||||
self.class.mismatches.include?(mismatch)
|
||||
end
|
||||
end
|
22
app/validators/contact/ident/national_id_validator.rb
Normal file
22
app/validators/contact/ident/national_id_validator.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
class Contact::Ident::NationalIdValidator < ActiveModel::EachValidator
|
||||
def self.country_specific_validations
|
||||
{
|
||||
Country.new('EE') => proc { |code| Isikukood.new(code).valid? },
|
||||
}
|
||||
end
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
validation = validation_for(record.country)
|
||||
|
||||
return unless validation
|
||||
|
||||
valid = validation.call(value)
|
||||
record.errors.add(attribute, :invalid_national_id, country: record.country) unless valid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validation_for(country)
|
||||
self.class.country_specific_validations[country]
|
||||
end
|
||||
end
|
21
app/validators/contact/ident/reg_no_validator.rb
Normal file
21
app/validators/contact/ident/reg_no_validator.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
class Contact::Ident::RegNoValidator < ActiveModel::EachValidator
|
||||
def self.country_specific_formats
|
||||
{
|
||||
Country.new('EE') => /\A[0-9]{8}\z/,
|
||||
}
|
||||
end
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
format = format_for(record.country)
|
||||
|
||||
return unless format
|
||||
|
||||
record.errors.add(attribute, :invalid_reg_no, country: record.country) unless value =~ format
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_for(country)
|
||||
self.class.country_specific_formats[country]
|
||||
end
|
||||
end
|
|
@ -19,7 +19,7 @@
|
|||
.col-md-3
|
||||
.form-group
|
||||
= label_tag t(:ident_type)
|
||||
= select_tag '[q][ident_type_eq]', options_for_select(Contact::IDENT_TYPES, params[:q][:ident_type_eq]), { include_blank: true, placeholder: t(:choose), class: 'form-control selectize' }
|
||||
= select_tag '[q][ident_type_eq]', options_for_select(ident_types, params[:q][:ident_type_eq]), { include_blank: true, placeholder: t(:choose), class: 'form-control selectize' }
|
||||
.row
|
||||
.col-md-3
|
||||
.form-group
|
||||
|
@ -75,7 +75,7 @@
|
|||
.row
|
||||
.col-md-12
|
||||
.table-responsive
|
||||
%table.table.table-hover.table-bordered.table-condensed
|
||||
%table.table.table-hover.table-bordered.table-condensed.contacts
|
||||
%thead
|
||||
%tr
|
||||
%th{class: 'col-xs-2'}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
.col-md-3
|
||||
.form-group
|
||||
= label_tag t(:ident_type)
|
||||
= select_tag '[q][ident_type_eq]', options_for_select(Contact::IDENT_TYPES, params[:q][:ident_type_eq]), { include_blank: true, placeholder: t(:choose), class: 'form-control selectize' }
|
||||
= select_tag '[q][ident_type_eq]', options_for_select(ident_types, params[:q][:ident_type_eq]), { include_blank: true, placeholder: t(:choose), class: 'form-control selectize' }
|
||||
.row
|
||||
.col-md-3
|
||||
.form-group
|
||||
|
@ -85,7 +85,7 @@
|
|||
.row
|
||||
.col-md-12
|
||||
.table-responsive
|
||||
%table.table.table-hover.table-bordered.table-condensed
|
||||
%table.table.table-hover.table-bordered.table-condensed.contacts
|
||||
%thead
|
||||
%tr
|
||||
%th{class: 'col-xs-2'}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue