aeternitas 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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>