-
Notifications
You must be signed in to change notification settings - Fork 0
Make Room a model #546
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Make Room a model #546
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class SyncRoomToSsoJob < ApplicationJob | ||
| queue_as :default | ||
|
|
||
| retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 30.seconds, attempts: 5 | ||
|
|
||
| def perform(user_id) | ||
| user = User.find_by(id: user_id) | ||
| return if user.nil? | ||
|
|
||
| SsoMetadataService.new.sync_room(user) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class Room < ApplicationRecord | ||
| belongs_to :user, optional: true, inverse_of: :room | ||
|
|
||
| validates :number, presence: true, uniqueness: true, length: { maximum: 6 }, | ||
| format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' } | ||
| # A room group represents the natural grouping of rooms. It can be the room number itself or a shared identifier | ||
| validates :group, presence: true, length: { maximum: 6 }, | ||
| format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' } | ||
| validates :building, presence: true, inclusion: { in: ('A'..'F').to_a } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is "Foyer" in the F building? Accueil or Halle projet rooms in A?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Answered by the seed |
||
| validates :floor, presence: true, inclusion: { in: 0..3 } | ||
| validates :user_id, uniqueness: true, allow_nil: true | ||
|
|
||
| after_commit :enqueue_room_sync_to_sso, on: [:create, :update], if: :saved_change_to_user_id? | ||
|
|
||
| # Returns rooms available for assignment: unoccupied rooms + the room already assigned to the given user | ||
| scope :available_for, ->(user) { where(user_id: [nil, user.id]) } | ||
|
|
||
| private | ||
|
|
||
| def enqueue_room_sync_to_sso | ||
| SyncRoomToSsoJob.perform_later(user_id) if user_id.present? | ||
| SyncRoomToSsoJob.perform_later(user_id_before_last_save) if user_id_before_last_save.present? | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class SsoMetadataService | ||
| SSO_BASE_URL = 'https://sso.rezoleo.fr' | ||
| HTTP_OPEN_TIMEOUT_SECONDS = 5 | ||
| HTTP_READ_TIMEOUT_SECONDS = 10 | ||
|
|
||
| # @param user [User] | ||
| def sync_room(user) | ||
| return if user.oidc_id.blank? | ||
|
|
||
| room_number = user.room&.number | ||
|
|
||
| unless production? | ||
| Rails.logger.info("[SSO] Dry-run: would sync room '#{room_number}' for user #{user.oidc_id}") | ||
| return | ||
| end | ||
|
|
||
| push_room_metadata(user, room_number) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def production? | ||
| Rails.env.production? | ||
| end | ||
|
|
||
| def push_room_metadata(user, room_number) | ||
| if room_number.present? | ||
| post_room_metadata(user, room_number) | ||
| else | ||
| delete_room_metadata(user) | ||
| end | ||
| end | ||
|
|
||
| # Metadata values in Zitadel must be base64-encoded. | ||
| # See https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.SetUserMetadata | ||
| def post_room_metadata(user, room_number) | ||
| uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata") | ||
| body = { metadata: [{ key: 'room', value: Base64.strict_encode64(room_number) }] } | ||
|
|
||
| req = build_request(Net::HTTP::Post, uri, body:) | ||
| res = execute_request(user:, req:) | ||
|
|
||
| return if res.is_a?(Net::HTTPSuccess) | ||
|
|
||
| log_failure(user, uri, req, res) | ||
| end | ||
|
|
||
| def delete_room_metadata(user) | ||
| uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata") | ||
| uri.query = URI.encode_www_form([['keys', 'room']]) | ||
|
|
||
| req = build_request(Net::HTTP::Delete, uri) | ||
| res = execute_request(user:, req:) | ||
|
|
||
| return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound) # NotFound => No existing metadata to delete | ||
|
|
||
| log_failure(user, uri, req, res) | ||
| end | ||
|
|
||
| def build_request(http_method, uri, body: nil) | ||
| req = http_method.new(uri) | ||
| req['Authorization'] = "Bearer #{access_token}" | ||
|
|
||
| return req if body.nil? | ||
|
|
||
| req.content_type = 'application/json' | ||
| req.body = body.to_json | ||
| req | ||
| end | ||
|
|
||
| def execute_request(user:, req:) | ||
| Net::HTTP.start( | ||
| req.uri.hostname, | ||
| req.uri.port, | ||
| use_ssl: true, | ||
| open_timeout: HTTP_OPEN_TIMEOUT_SECONDS, | ||
| read_timeout: HTTP_READ_TIMEOUT_SECONDS | ||
| ) do |http| | ||
| http.request(req) | ||
| end | ||
| rescue Net::OpenTimeout, Net::ReadTimeout => e | ||
| Rails.logger.error("[SSO] Timeout for user #{user.oidc_id}: #{e.message}") | ||
| raise | ||
| rescue StandardError => e | ||
| Rails.logger.error("[SSO] Error syncing room for user #{user.oidc_id}: #{e.message}") | ||
| raise | ||
| end | ||
|
|
||
| def log_failure(user, uri, req, res) | ||
| Rails.logger.error( | ||
| "[SSO] Failed to sync room for user #{user.oidc_id} " \ | ||
| "(#{req.method} #{uri}): #{res.code} #{res.body}" | ||
| ) | ||
| end | ||
|
|
||
| def access_token | ||
| Rails.application.credentials.sso_lea5_pat! | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,8 +20,13 @@ | |
| </div> | ||
| <div> | ||
| <%= f.label :room %> | ||
| <%= f.text_field :room, placeholder: 'A123b or DF1', minlength: 3, maxlength: 5, | ||
| pattern: '([A-Fa-f][0-3][0-9]{2}[a-b]?|[Dd][Ff][1-4])' %> | ||
| <%= f.text_field :room_number, list: 'rooms-datalist', autocomplete: 'off', | ||
| placeholder: 'ex : A105A', value: f.object.room&.number %> | ||
| <datalist id="rooms-datalist"> | ||
| <% Room.available_for(f.object).order(:number).each do |room| %> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This query should be hoisted to the controller and passed as a local variable. |
||
| <option value="<%= room.number %>"></option> | ||
| <% end %> | ||
| </datalist> | ||
| </div> | ||
| <div> | ||
| <%= f.submit yield(:button_text) %> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class CreateRooms < ActiveRecord::Migration[7.2] | ||
| def change | ||
| create_table :rooms do |t| | ||
| t.string :number, limit: 6, null: false | ||
| t.string :group, limit: 6, null: false | ||
|
Comment on lines
+6
to
+7
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 6 characters limit is arbitrary? (as arbitrary as the regex before)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, to fit the longest number I thought of : |
||
| t.string :building, limit: 1, null: false | ||
| t.integer :floor, null: false | ||
| t.references :user, foreign_key: true, index: { unique: true, where: 'user_id IS NOT NULL' } | ||
|
|
||
| t.timestamps | ||
| end | ||
| add_index :rooms, :number, unique: true | ||
| add_index :rooms, :group | ||
| add_index :rooms, [:building, :floor] | ||
|
|
||
| # Migrate existing user.room data to rooms.user_id | ||
| reversible do |dir| | ||
| dir.up do | ||
| execute <<~SQL.squish | ||
| UPDATE rooms SET user_id = users.id FROM users WHERE users.room = rooms.number | ||
| SQL | ||
| end | ||
| end | ||
|
|
||
| # Remove the old room column from users | ||
| remove_index :users, :room, name: 'index_users_on_room' | ||
| remove_column :users, :room, :string | ||
| end | ||
| end | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a comment/example to understand what a "group" is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Before I write it in the code, what do you think about: