aeternitas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ require 'aasm'
2
+
3
+ module Aeternitas
4
+ # Stores the meta data of all pollables
5
+ # Every pollable needs to have exactly one meta data object
6
+ class PollableMetaData < ActiveRecord::Base
7
+ self.table_name = 'aeternitas_pollable_meta_data'
8
+
9
+ include AASM
10
+ ######
11
+ # create_table aeternitas_pollable_meta_data do |t|
12
+ # t.string :pollable_type, null: false
13
+ # t.integer :pollable_id, null: false
14
+ # t.string :pollable_class, null: false
15
+ # t.datetime :next_polling, null: false, default: "1970-01-01 00:00:00+002"
16
+ # t.datetime :last_polling
17
+ # t.string :state
18
+ # t.text :deactivation_reason
19
+ # t.datetime :deactivated_at
20
+ # end
21
+ # create_index :aeternitas_pollable_meta_data, [:pollable_id, :pollable_type], name: 'aeternitas_pollable_unique', unique: true
22
+ # create_index :aeternitas_pollable_meta_data, [:next_polling, :state], name: 'aeternitas_pollable_enqueueing'
23
+ # create_index :aeternitas_pollable_meta_data, [:pollable_class], name: 'aeternitas_pollable_class'
24
+ ######
25
+
26
+ belongs_to :pollable, polymorphic: true
27
+
28
+ validates :pollable_type, presence: true, uniqueness: { scope: :pollable_id }
29
+ validates :pollable_id, presence: true, uniqueness: { scope: :pollable_type }
30
+ validates :pollable_class, presence: true
31
+ validates :next_polling, presence: true
32
+
33
+ aasm column: :state do
34
+ state :waiting, initial: true
35
+ state :enqueued
36
+ state :active
37
+ state :deactivated
38
+ state :errored
39
+
40
+ event :enqueue do
41
+ transitions from: %i[waiting deactivated errored], to: :enqueued
42
+ end
43
+
44
+ event :poll do
45
+ transitions from: %i[waiting enqueued errored], to: :active
46
+ end
47
+
48
+ event :has_errored do
49
+ transitions from: :active, to: :errored
50
+ end
51
+
52
+ event :wait do
53
+ transitions from: :active, to: :waiting
54
+ end
55
+
56
+ event :deactivate do
57
+ transitions to: :deactivated
58
+ end
59
+ end
60
+
61
+ scope(:due, ->() { waiting.where('next_polling < ?', Time.now) })
62
+
63
+ # Disables polling of this instance
64
+ #
65
+ # @param [String] reason Reason for the deactivation. (E.g. an error message)
66
+ def disable_polling(reason = nil)
67
+ self.deactivate
68
+ self.deactivation_reason = reason.to_s
69
+ self.deactivated_at = Time.now
70
+ self.save!
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,24 @@
1
+ module Aeternitas
2
+ # Stores default polling frequency calculation methods.
3
+ module PollingFrequency
4
+ HOURLY = ->(context) { Time.now + 1.hour }
5
+ DAILY = ->(context) { Time.now + 1.day }
6
+ WEEKLY = ->(context) { Time.now + 1.week }
7
+ MONTHLY = ->(context) { Time.now + 1.month }
8
+
9
+ # Retrieves the build-in polling frequency methods by name.
10
+ #
11
+ # @param [Symbol] name the frequency method
12
+ # @return [Lambda] Polling frequency method
13
+ # @raise [ArgumentError] if the preset does not exist
14
+ def self.by_name(name)
15
+ case name
16
+ when :hourly then HOURLY
17
+ when :daily then DAILY
18
+ when :weekly then WEEKLY
19
+ when :monthly then MONTHLY
20
+ else raise(ArgumentError, "Unknown polling frequency: #{name}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ require 'aeternitas/sidekiq/poll_job'
2
+ require 'aeternitas/sidekiq/middleware'
3
+ module Aeternitas
4
+ module Sidekiq ; end
5
+ end
@@ -0,0 +1,31 @@
1
+ module Aeternitas
2
+ module Sidekiq
3
+ # Aeternitas Sidekiq Middleware
4
+ class Middleware
5
+
6
+ def call(worker, msg, queue)
7
+ yield
8
+ rescue Aeternitas::Guard::GuardIsLocked => e
9
+ raise e unless worker.is_a? Aeternitas::Sidekiq::PollJob
10
+
11
+ # try deleting the sidekiq unique key so the job can be reenqueued
12
+ Aeternitas.redis.del(
13
+ SidekiqUniqueJobs::UniqueArgs.digest(msg)
14
+ )
15
+
16
+ # reenqueue the job
17
+ worker.class.client_push msg
18
+
19
+ # update the pollables state
20
+ meta_data = Aeternitas::PollableMetaData.find_by(id: msg['args'].first)
21
+ meta_data.enqueue!
22
+
23
+ if meta_data.pollable.pollable_configuration.sleep_on_guard_locked
24
+ # put the worker to rest for errors timeout
25
+ sleep_duration = (e.timeout - Time.now).to_i
26
+ sleep(sleep_duration + 1.0) if sleep_duration > 0
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ require 'sidekiq'
2
+ module Aeternitas
3
+ module Sidekiq
4
+ # Sidekiq Worker that is responsible for executing the polling.
5
+ class PollJob
6
+ include ::Sidekiq::Worker
7
+
8
+ sidekiq_options unique: :until_executed,
9
+ unique_args: [:pollable_meta_data_id],
10
+ unique_job_expiration: 1.month.to_i,
11
+ queue: :polling,
12
+ retry: 4
13
+
14
+ sidekiq_retry_in do |count|
15
+ [60, 3600, 86400, 604800][count]
16
+ end
17
+
18
+ sidekiq_retries_exhausted do |msg|
19
+ meta_data = Aeternitas::PollableMetaData.find_by!(id: msg['args'].first)
20
+ meta_data.disable_polling(msg['error_message'])
21
+ end
22
+
23
+ def perform(pollable_meta_data_id)
24
+ meta_data = Aeternitas::PollableMetaData.find_by(id: pollable_meta_data_id)
25
+ pollable = meta_data.pollable
26
+ pollable.execute_poll
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ module Aeternitas
2
+ # Sources can store polling results in a write once - read many fashion.
3
+ # Each source, by default, hereby stores it's raw_content in a compressed file on disk if it has not been saved yet
4
+ # (determined by the raw_contents MD5 Hash)
5
+ # The 'aeternitas_sources' table holds all source metadata in a quite space-efficient way. For instance the compressed
6
+ # file's location is determined from the sources fingerprint which at the same time is it's database ID.
7
+ class Source < ActiveRecord::Base
8
+ ######
9
+ # create_table :aeternitas_sources, id: :string, primary_key: :fingerprint do |t|
10
+ # t.string :pollable_type, null: false
11
+ # t.integer :pollable_id, null: false
12
+ # t.datetime :created_at
13
+ # end
14
+ # add_index :aeternitas_sources, [:pollable_id, :pollable_type], name: 'aeternitas_pollable_source'
15
+ ######
16
+ self.table_name = 'aeternitas_sources'
17
+
18
+ attr_writer :raw_content
19
+
20
+ belongs_to :pollable, polymorphic: true
21
+
22
+ after_initialize :ensure_fingerprint
23
+
24
+ validates :raw_content, presence: true, on: :create
25
+ validates :fingerprint, presence: true, uniqueness: true
26
+
27
+ # Ensure that the file was created before the record is saved
28
+ before_create :create_file
29
+
30
+ # Make sure to delete the file if the transaction that includes the creation is aborted
31
+ after_rollback :delete_file, on: :create
32
+
33
+ # Make sure to delete the file only if the record was safely destroyed
34
+ after_commit :delete_file, on: :destroy
35
+
36
+ # Generates the entries fingerprint.
37
+ # @return [String] the entries fingerprint.
38
+ def generate_fingerprint
39
+ Digest::MD5.hexdigest(@raw_content.to_s)
40
+ end
41
+
42
+ # Get the sources raw content.
43
+ # @return [String] the sources raw content
44
+ def raw_content
45
+ @raw_content ||= Aeternitas.config.get_storage_adapter.retrieve(self.fingerprint)
46
+ end
47
+
48
+ private
49
+
50
+ def create_file
51
+ Aeternitas.config.get_storage_adapter.store(self.fingerprint, raw_content)
52
+ end
53
+
54
+ def delete_file
55
+ Aeternitas.config.get_storage_adapter.delete(self.fingerprint)
56
+ end
57
+
58
+ def ensure_fingerprint
59
+ self.fingerprint ||= generate_fingerprint
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,46 @@
1
+ require "aeternitas/storage_adapter/file"
2
+ module Aeternitas
3
+ # Storage Adapters take care of handling source files.
4
+ # @abstract Create a subclass and override {#store}, #{retrieve} and #{#delete} to create a new storage adapter
5
+ class StorageAdapter
6
+
7
+ # Create a new storage adapter
8
+ # @param [Hash] config the adapters configuration
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Store a new entry with the given id and raw content
14
+ # @abstract
15
+ # @param [String] id the entries fingerprint
16
+ # @param [Object] raw_content the raw content object
17
+ def store(id, raw_content)
18
+ raise NotImplementedError, "#{self.class.name} does not implement #store, required by Aeternitas::StorageAdapter"
19
+ end
20
+
21
+ # Retrieves the content of the entry with the given fingerprint
22
+ # @abstract
23
+ # @param [String] id the entries fingerprint
24
+ # @return [String] the entries content
25
+ def retrieve(id)
26
+ raise NotImplementedError, "#{self.class.name} does not implement #retrive, required by Aeternitas::StorageAdapter"
27
+ end
28
+
29
+ # Delete the entry with the given fingerprint
30
+ # @abstract
31
+ # @param [String] id the entries fingerprint
32
+ # @return [Boolean] Operation state
33
+ def delete(id)
34
+ raise NotImplementedError, "#{self.class.name} does not implement #delete, required by Aeternitas::StorageAdapter"
35
+ end
36
+
37
+ # Checks whether the entry with the given fingerprint exists.
38
+ # @abstract
39
+ # @param [String] id the entries id
40
+ # @return [Boolean] if the entry exists
41
+ def exist?(id)
42
+ raise NotImplementedError, "#{self.class.name} does not implement #exist?, required by Aeternitas::StorageAdapter"
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,73 @@
1
+ module Aeternitas
2
+ class StorageAdapter
3
+ # A storage adapter that stores the entries on disk.
4
+ class File < Aeternitas::StorageAdapter
5
+
6
+ # Create a new File storage adapter.
7
+ # @param [Hash] config the adapters config
8
+ # @option config [String] :directory specifies where the entries are stored
9
+ def initialize(config)
10
+ super
11
+ end
12
+
13
+ def store(id, raw_content)
14
+ path = file_path(id)
15
+ ensure_folders_exist(path)
16
+ raise(Aeternitas::Errors::SourceDataExists, id) if ::File.exist?(path)
17
+ ::File.open(path, 'w+', encoding: 'ascii-8bit') do |f|
18
+ f.write(Zlib.deflate(raw_content, Zlib::BEST_COMPRESSION))
19
+ end
20
+ end
21
+
22
+ def retrieve(id)
23
+ raise(Aeternitas::Errors::SourceDataNotFound, id) unless exist?(id)
24
+ Zlib.inflate(::File.read(file_path(id), encoding: 'ascii-8bit'))
25
+ end
26
+
27
+ def delete(id)
28
+ begin
29
+ !!::File.delete(file_path(id))
30
+ rescue Errno::ENOENT => e
31
+ return false
32
+ end
33
+ end
34
+
35
+ def exist?(id)
36
+ ::File.exist?(file_path(id))
37
+ end
38
+
39
+ # Returns the raw_content's size in bytes
40
+ # @param [String] id the entries fingerprint
41
+ # @return [Integer] the entries size in byte
42
+ def content_size(id)
43
+ retrieve(id).bytesize
44
+ end
45
+
46
+ # Returns the raw_content compressed size in bytes
47
+ # @param [String] id the entries fingerprint
48
+ # @return [Integer] the entries size on disk in byte
49
+ def file_size_disk(id)
50
+ ::File.size(file_path(id))
51
+ end
52
+
53
+ private
54
+
55
+ # Calculates the location of the raw_content file given it's fingerprint.
56
+ # @param [String] id the entries fingerprint
57
+ # @return [String] the entries location
58
+ def file_path(id)
59
+ ::File.join(
60
+ @config[:directory],
61
+ id[0..1], id[2..3], id[4..5],
62
+ id[6..-1]
63
+ )
64
+ end
65
+
66
+ # Makes sure that the storage location exists.
67
+ def ensure_folders_exist(path)
68
+ folders = ::File.dirname(path)
69
+ FileUtils.mkdir_p(folders) unless Dir.exist?(folders)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,3 @@
1
+ module Aeternitas
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,35 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Aeternitas
5
+ # Installs Aeternitas in a rails app.
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("../templates", __FILE__)
10
+
11
+ desc 'Generates (but does not run) a migration to add all tables needed by Aeternitas.' \
12
+ ' Also generates an initializer file for configuring Aeternitas'
13
+
14
+ def create_migration_file
15
+ migration_dir = File.expand_path("db/migrate")
16
+ if self.class.migration_exists?(migration_dir, 'add_aeternitas')
17
+ ::Kernel.warn "Migration already exists: #{template}"
18
+ else
19
+ migration_template('add_aeternitas.rb.erb', 'db/migrate/add_aeternitas.rb')
20
+ end
21
+ end
22
+
23
+ def copy_initializer
24
+ copy_file('initializer.rb', 'config/initializers/aeternitas.rb')
25
+ end
26
+
27
+ def reminder
28
+ say "Don't forget to regularly run 'Aeternitas.enqueue_due_pollables'. E.g using 'whenever'", :red
29
+ end
30
+
31
+ def self.next_migration_number(dirname)
32
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # This migration creates the tables needed by Aeternitas
2
+ class AddAeternitas < ActiveRecord::Migration
3
+ def change
4
+ create_table :aeternitas_pollable_meta_data do |t|
5
+ t.string :pollable_type, null: false
6
+ t.integer :pollable_id, null: false
7
+ t.string :pollable_class, null: false
8
+ t.datetime :next_polling, null: false, default: "1970-01-01 00:00:00+002"
9
+ t.datetime :last_polling
10
+ t.string :state
11
+ t.text :deactivation_reason
12
+ t.datetime :deactivated_at
13
+ end
14
+ add_index :aeternitas_pollable_meta_data, [:pollable_id, :pollable_type], name: 'aeternitas_pollable_unique', unique: true
15
+ add_index :aeternitas_pollable_meta_data, [:next_polling, :state], name: 'aeternitas_pollable_enqueueing'
16
+ add_index :aeternitas_pollable_meta_data, [:pollable_class], name: 'aeternitas_pollable_class'
17
+
18
+ create_table :aeternitas_sources, id: :string, primary_key: :fingerprint do |t|
19
+ t.string :pollable_type, null: false
20
+ t.integer :pollable_id, null: false
21
+ t.datetime :created_at
22
+ end
23
+ add_index :aeternitas_sources, [:pollable_id, :pollable_type], name: 'aeternitas_pollable_source'
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ # Configure Aeternitas
2
+ Aeternitas.configure do |config|
3
+ config.redis = { host: 'localhost', port: 6379 } #this is the default Redis config which should work in most cases.
4
+ end
5
+
6
+ Sidekiq.configure_server do |config|
7
+ config.server_middleware do |chain|
8
+ chain.add Aeternitas::Sidekiq::Middleware
9
+ end
10
+ end
Binary file
@@ -0,0 +1,198 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
3
+
4
+ <svg
5
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
6
+ xmlns:cc="http://creativecommons.org/ns#"
7
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
8
+ xmlns:svg="http://www.w3.org/2000/svg"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
12
+ width="210mm"
13
+ height="297mm"
14
+ viewBox="0 0 210 297"
15
+ version="1.1"
16
+ id="svg8"
17
+ inkscape:version="0.92.1 r"
18
+ sodipodi:docname="Zeichnung.svg">
19
+ <defs
20
+ id="defs2" />
21
+ <sodipodi:namedview
22
+ id="base"
23
+ pagecolor="#ffffff"
24
+ bordercolor="#666666"
25
+ borderopacity="1.0"
26
+ inkscape:pageopacity="0.0"
27
+ inkscape:pageshadow="2"
28
+ inkscape:zoom="7.9195959"
29
+ inkscape:cx="156.46393"
30
+ inkscape:cy="658.66545"
31
+ inkscape:document-units="mm"
32
+ inkscape:current-layer="flowRoot3680"
33
+ showgrid="false"
34
+ inkscape:window-width="1920"
35
+ inkscape:window-height="1141"
36
+ inkscape:window-x="1920"
37
+ inkscape:window-y="0"
38
+ inkscape:window-maximized="1" />
39
+ <metadata
40
+ id="metadata5">
41
+ <rdf:RDF>
42
+ <cc:Work
43
+ rdf:about="">
44
+ <dc:format>image/svg+xml</dc:format>
45
+ <dc:type
46
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
47
+ <dc:title></dc:title>
48
+ </cc:Work>
49
+ </rdf:RDF>
50
+ </metadata>
51
+ <g
52
+ inkscape:label="Ebene 1"
53
+ inkscape:groupmode="layer"
54
+ id="layer1">
55
+ <g
56
+ aria-label="Æ"
57
+ transform="scale(0.26458333)"
58
+ style="font-style:normal;font-weight:normal;font-size:40px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
59
+ id="flowRoot3680">
60
+ <path
61
+ style="fill:none;stroke:#000000;stroke-width:1.8368504;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
62
+ d="m 129.11306,438.19859 c -2.84852,2.31146 -6.97885,4.09709 -11.01739,4.09709 -9.00781,0 -16.31009,-6.94251 -16.31009,-15.50652 0,-8.56401 7.30228,-15.50652 16.31009,-15.50652 2.92238,0 6.18611,0.14673 8.55835,1.42648"
63
+ id="path3696"
64
+ inkscape:connector-curvature="0"
65
+ sodipodi:nodetypes="csssc"
66
+ inkscape:export-xdpi="570.17267"
67
+ inkscape:export-ydpi="570.17267" />
68
+ <path
69
+ d="M 136.61211,438.93865 H 121.74883 V 430.091 h -10 l -6.86668,12.96704 h -3.71094 l 16.12449,-32.67408 h 19.31641 v 3.16407 h -11.26953 v 8.90625 h 10.50781 v 3.125 h -10.50781 v 10.19531 h 11.26953 z M 113.13555,426.9074 h 8.61328 v -13.32031 h -2.32422 z"
70
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans';stroke:#000000;fill:#2c89a0;stroke-opacity:1.0"
71
+ id="path3688"
72
+ inkscape:connector-curvature="0"
73
+ sodipodi:nodetypes="cccccccccccccccccccccc"
74
+ inkscape:export-xdpi="570.17267"
75
+ inkscape:export-ydpi="570.17267" />
76
+ <path
77
+ style="font-style:normal;font-weight:normal;font-size:40px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.8368504;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
78
+ d="m 129.302,475.53626 c -2.84852,2.31146 -6.97885,4.09709 -11.01739,4.09709 -9.00781,0 -16.31009,-6.94251 -16.31009,-15.50652 0,-8.56401 7.30228,-15.50652 16.31009,-15.50652 2.92238,0 6.18611,0.14673 8.55835,1.42648"
79
+ id="path3696-8"
80
+ inkscape:connector-curvature="0"
81
+ sodipodi:nodetypes="csssc"
82
+ inkscape:export-xdpi="570.17267"
83
+ inkscape:export-ydpi="570.17267" />
84
+ <path
85
+ d="m 136.80105,476.27632 h -14.86328 v -8.84765 h -10 l -6.86668,12.96704 h -3.71094 l 16.12449,-32.67408 h 19.31641 v 3.16407 h -11.26953 v 8.90625 h 10.50781 v 3.125 h -10.50781 v 10.19531 h 11.26953 z m -23.47656,-12.03125 h 8.61328 v -13.32031 h -2.32422 z"
86
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:40px;line-height:125%;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans';letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0"
87
+ id="path3688-0"
88
+ inkscape:connector-curvature="0"
89
+ sodipodi:nodetypes="cccccccccccccccccccccc"
90
+ inkscape:export-xdpi="570.17267"
91
+ inkscape:export-ydpi="570.17267" />
92
+ <text
93
+ xml:space="preserve"
94
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
95
+ x="134.15927"
96
+ y="476.14157"
97
+ id="text4524"
98
+ inkscape:export-xdpi="570.17267"
99
+ inkscape:export-ydpi="570.17267"><tspan
100
+ sodipodi:role="line"
101
+ id="tspan4522"
102
+ x="134.15927"
103
+ y="476.14157"
104
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;stroke-width:0.7413125px;stroke:#000000;fill:#2c89a0;stroke-opacity:1.0;">T</tspan></text>
105
+ <text
106
+ xml:space="preserve"
107
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
108
+ x="145.95291"
109
+ y="476.13583"
110
+ id="text4524-6"
111
+ inkscape:export-xdpi="570.17267"
112
+ inkscape:export-ydpi="570.17267"><tspan
113
+ sodipodi:role="line"
114
+ id="tspan4522-5"
115
+ x="145.95291"
116
+ y="476.13583"
117
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">E</tspan></text>
118
+ <text
119
+ xml:space="preserve"
120
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
121
+ x="156.73026"
122
+ y="476.13657"
123
+ id="text4524-0"
124
+ inkscape:export-xdpi="570.17267"
125
+ inkscape:export-ydpi="570.17267"><tspan
126
+ sodipodi:role="line"
127
+ id="tspan4522-8"
128
+ x="156.73026"
129
+ y="476.13657"
130
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">R</tspan></text>
131
+ <text
132
+ xml:space="preserve"
133
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
134
+ x="170.48477"
135
+ y="476.12665"
136
+ id="text4524-1"
137
+ inkscape:export-xdpi="570.17267"
138
+ inkscape:export-ydpi="570.17267"><tspan
139
+ sodipodi:role="line"
140
+ id="tspan4522-2"
141
+ x="170.48477"
142
+ y="476.12665"
143
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">N</tspan></text>
144
+ <text
145
+ xml:space="preserve"
146
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
147
+ x="187.77988"
148
+ y="476.1207"
149
+ id="text4524-2"
150
+ inkscape:export-xdpi="570.17267"
151
+ inkscape:export-ydpi="570.17267"><tspan
152
+ sodipodi:role="line"
153
+ id="tspan4522-6"
154
+ x="187.77988"
155
+ y="476.1207"
156
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">I</tspan></text>
157
+ <text
158
+ xml:space="preserve"
159
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
160
+ x="195.93016"
161
+ y="476.12857"
162
+ id="text4524-9"
163
+ inkscape:export-xdpi="570.17267"
164
+ inkscape:export-ydpi="570.17267"><tspan
165
+ sodipodi:role="line"
166
+ id="tspan4522-1"
167
+ x="195.93016"
168
+ y="476.12857"
169
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">T</tspan></text>
170
+ <text
171
+ xml:space="preserve"
172
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
173
+ x="204.82211"
174
+ y="476.12311"
175
+ id="text4524-04"
176
+ inkscape:export-xdpi="570.17267"
177
+ inkscape:export-ydpi="570.17267"><tspan
178
+ sodipodi:role="line"
179
+ id="tspan4522-27"
180
+ x="204.82211"
181
+ y="476.12311"
182
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">A</tspan></text>
183
+ <text
184
+ xml:space="preserve"
185
+ style="font-style:normal;font-weight:normal;font-size:29.65250015px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#2c89a0;fill-opacity:1;stroke:#000000;stroke-width:0.7413125px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1.0;"
186
+ x="219.43689"
187
+ y="475.82886"
188
+ id="text4524-4"
189
+ inkscape:export-xdpi="570.17267"
190
+ inkscape:export-ydpi="570.17267"><tspan
191
+ sodipodi:role="line"
192
+ id="tspan4522-0"
193
+ x="219.43689"
194
+ y="475.82886"
195
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Italic';letter-spacing:0px;fill:#2c89a0;stroke:#000000;stroke-width:0.7413125px;stroke-opacity:1.0;">S</tspan></text>
196
+ </g>
197
+ </g>
198
+ </svg>