Skip to content
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

Add sites and audit #2

Merged
merged 5 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ gem "bootsnap", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"

# Add optimized Active Record association methods
gem "activerecord-has_some_of_many"

# Pagination
gem "pagy"

Expand All @@ -49,6 +52,8 @@ gem "dsfr-assets"
gem "dsfr-form_builder"
gem "dsfr-view-components"

gem "friendly_id"

group :development, :test do
gem "pry-rails"

Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ GEM
activemodel (= 8.0.1)
activesupport (= 8.0.1)
timeout (>= 0.4.0)
activerecord-has_some_of_many (1.2.0)
activerecord (>= 7.0.0.alpha)
railties (>= 7.0.0.alpha)
activestorage (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
Expand Down Expand Up @@ -194,6 +197,8 @@ GEM
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
formatador (1.1.0)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
Expand Down Expand Up @@ -542,6 +547,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
activerecord-has_some_of_many
axe-core-capybara (~> 4.9)
axe-core-rspec (~> 4.8)
better_errors
Expand All @@ -557,6 +563,7 @@ DEPENDENCIES
dsfr-view-components
factory_bot_rails
faker
friendly_id
guard
guard-cucumber
guard-rspec
Expand Down
57 changes: 57 additions & 0 deletions app/controllers/sites_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class SitesController < ApplicationController
before_action :set_site, except: :index
before_action :redirect_old_slugs, except: [:index, :new, :create], if: :get_request?

# GET /sites
def index
@pagy, @sites = pagy Site.includes(:audit)
end

# GET /sites/1
def show; end

# GET /sites/new
def new; end

# GET /sites/1/edit
def edit; end

# POST /sites
def create
@site = Site.find_or_create_by_url(site_params)
if @site.save
redirect_to @site, notice: t(".notice")
else
render :new, status: :unprocessable_entity
end
end

# PATCH/PUT /sites/1
def update
if @site.update(site_params)
redirect_to @site, notice: t(".notice"), status: :see_other
else
render :edit, status: :unprocessable_entity
end
end

# DELETE /sites/1
def destroy
@site.destroy!
redirect_to sites_path, notice: t(".notice"), status: :see_other
end

private

def set_site
@site = params[:id].present? ? Site.friendly.find(params.expect(:id)) : Site.new_with_audit
end

def redirect_old_slugs
redirect_to(@site, status: :moved_permanently) unless @site.slug == params[:id]
end

def site_params
params.expect(site: [:url])
end
end
32 changes: 32 additions & 0 deletions app/models/audit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class Audit < ApplicationRecord
MAX_ATTEMPTS = 3
MAX_RUNTIME = 1.hour.freeze

belongs_to :site, touch: true

validates :url, presence: true, url: true
normalizes :url, with: ->(url) { URI.parse(url).normalize.to_s }

enum :status, ["pending", "running", "passed", "retryable", "failed"].index_by(&:itself), validate: true, default: :pending

scope :sort_by_newest, -> { order(created_at: :desc) }
scope :sort_by_url, -> { order(Arel.sql("REGEXP_REPLACE(url, '^https?://(www\.)?', '') ASC")) }
scope :due, -> { pending.where("run_at <= now()") }
scope :past, -> { where(status: [:passed, :failed]) }
scope :scheduled, -> { where("run_at > now()") }
scope :retryable, -> { failed.where(attempts: ...MAX_ATTEMPTS) }
scope :to_run, -> { due.or(retryable) }
scope :clean, -> { passed.where(attempts: 0) }
scope :late, -> { pending.where("run_at <= ?", 1.hour.ago) }
scope :retried, -> { passed.where(attempts: 1..) }
scope :stalled, -> { running.where("run_at < ?", MAX_RUNTIME.ago) }
scope :crashed, -> { failed.where(attempts: MAX_ATTEMPTS) }

delegate :hostname, :path, to: :parsed_url

def run_at = super || Time.zone.now
def due? = pending? && run_at <= Time.zone.now
def runnable? = due? || retryable?
def parsed_url = @parsed_url ||= URI.parse(url).normalize
def url_without_scheme = @url_without_scheme ||= [hostname, path == "/" ? nil : path].compact.join(nil)
end
49 changes: 49 additions & 0 deletions app/models/site.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
class Site < ApplicationRecord
extend FriendlyId

has_many :audits, dependent: :destroy
has_one_of_many :audit, -> { order("audits.created_at DESC") }, class_name: "Audit"

friendly_id :url_without_scheme, use: [:slugged, :history]

delegate :url, :url_without_scheme, to: :audit

class << self
def find_or_create_by_url(attributes)
url = attributes.to_h.delete(:url)
attributes.to_h.delete(:name) if attributes[:name].blank?
# Ignore http/https duplicates when searching
normalized_url = [url, url.sub(/^https?/, url.start_with?("https") ? "http" : "https")]
joins(:audits).find_by(audits: { url: normalized_url })&.tap { it.update(attributes) } \
|| create_with_audit(url:, **attributes)
end

def create_with_audit(url: nil, **attributes)
new_with_audit(url:, **attributes).tap(&:save)
end

def new_with_audit(url: nil, **attributes)
new(attributes).tap { |site| site.audits.build(url:) }
end
end

def new(attributes = nil)
return super if attributes.nil?

attributes = attributes.to_h.symbolize_keys

if url = attributes.delete(:url)
self.class.find_or_create_by(url:, **attributes)
else
super
end
end

def url=(new_url)
audit = audits.build(url: new_url)
end

def to_title = url_without_scheme
def audit = super || audits.last || audits.build
def should_generate_new_friendly_id? = new_record? || (audit && slug != url_without_scheme.parameterize)
end
12 changes: 12 additions & 0 deletions app/validators/url_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class UrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?

begin
uri = URI.parse(value)
return if uri.host.present? && uri.scheme.match?(/^https?$/)
rescue URI::InvalidURIError
end
record.errors.add(attribute, :invalid)
end
end
7 changes: 7 additions & 0 deletions app/views/sites/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<%= form_with(model: site) do |f| %>
<%= f.dsfr_input_field :url, :text_field, type: :url, required: true, autofocus: true, hint: "Saisissez une url valide, commençant par https:// ou http://" %>

<%= f.submit "Enregistrer", name: nil, class: "fr-btn", data: { disable_with: "Enregistrement…" } %>

<%= link_to "Annuler", { action: site.persisted? ? :show : :index }, class: "fr-btn fr-btn--secondary" %>
<% end %>
1 change: 1 addition & 0 deletions app/views/sites/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= render "form", site: @site %>
26 changes: 26 additions & 0 deletions app/views/sites/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<%= dsfr_table(caption: "Liste des sites") do |t| %>
<% t.with_head do %>
<tr>
<th scope="col"><%= Site.human(:url) %></th>
<th scope="col"><%= Site.human(:updated_at) %></th>
<th scope="col"><%= t("shared.actions") %></th>
</tr>
<% end %>
<% t.with_body do %>
<% @sites.each do |site| %>
<tr>
<td><%= link_to site.url_without_scheme, site %></td>
<td>
<%= l site.updated_at, format: :compact %>
<br>
(<%= time_ago site.updated_at %>)
</td>
<td><%= link_to "Voir le site", site.url, target: :_blank %></td>
</tr>
<% end %>
<% end %>
<% end %>

<%= paginate %>

<%= link_to "Ajouter un site", { action: :new }, class: "fr-btn" %>
1 change: 1 addition & 0 deletions app/views/sites/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= render "form", site: @site %>
16 changes: 16 additions & 0 deletions app/views/sites/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p>
<strong><%= Site.human :url %> :</strong>
<%= link_to @site.url, @site.url, target: :_blank %>
</p>
<p>
<strong><%= Site.human :updated_at %> :</strong>
<%= l @site.updated_at, format: :long %>
(<%= time_ago @site.updated_at %>).
</p>

<br>

<div class="fr-btn-group fr-mb-2w">
<%= button_to "Supprimer ce site", @site, method: :delete, class: "fr-btn fr-btn--tertiary", form: { data: { turbo_confirm: t("shared.confirm") } } %>
<%= link_to "Revenir à la liste", { action: :index }, class: "fr-btn fr-btn--tertiary-no-outline" %>
</div>
106 changes: 106 additions & 0 deletions config/initializers/friendly_id.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# FriendlyId Global Configuration
#
# Use this to set up shared configuration options for your entire application.
# Any of the configuration options shown here can also be applied to single
# models by passing arguments to the `friendly_id` class method or defining
# methods in your model.
#
# To learn more, check out the guide:
#
# http://norman.github.io/friendly_id/file.Guide.html

FriendlyId.defaults do |config|
# ## Reserved Words
#
# Some words could conflict with Rails's routes when used as slugs, or are
# undesirable to allow as slugs. Edit this list as needed for your app.
config.use :reserved

config.reserved_words = ["new", "edit", "index", "session", "login", "logout", "users", "admin", "stylesheets", "assets", "javascripts", "images"]

# This adds an option to treat reserved words as conflicts rather than exceptions.
# When there is no good candidate, a UUID will be appended, matching the existing
# conflict behavior.

# config.treat_reserved_as_conflict = true

# ## Friendly Finders
#
# Uncomment this to use friendly finders in all models. By default, if
# you wish to find a record by its friendly id, you must do:
#
# MyModel.friendly.find('foo')
#
# If you uncomment this, you can do:
#
# MyModel.find('foo')
#
# This is significantly more convenient but may not be appropriate for
# all applications, so you must explicitly opt-in to this behavior. You can
# always also configure it on a per-model basis if you prefer.
#
# Something else to consider is that using the :finders addon boosts
# performance because it will avoid Rails-internal code that makes runtime
# calls to `Module.extend`.
#
# config.use :finders
#
# ## Slugs
#
# Most applications will use the :slugged module everywhere. If you wish
# to do so, uncomment the following line.

config.use :slugged

# By default, FriendlyId's :slugged addon expects the slug column to be named
# 'slug', but you can change it if you wish.
#
# config.slug_column = 'slug'
#
# By default, slug has no size limit, but you can change it if you wish.
#
# config.slug_limit = 255
#
# When FriendlyId can not generate a unique ID from your base method, it appends
# a UUID, separated by a single dash. You can configure the character used as the
# separator. If you're upgrading from FriendlyId 4, you may wish to replace this
# with two dashes.
#
# config.sequence_separator = '-'
#
# Note that you must use the :slugged addon **prior** to the line which
# configures the sequence separator, or else FriendlyId will raise an undefined
# method error.
#
# ## Tips and Tricks
#
# ### Controlling when slugs are generated
#
# As of FriendlyId 5.0, new slugs are generated only when the slug field is
# nil, but if you're using a column as your base method can change this
# behavior by overriding the `should_generate_new_friendly_id?` method that
# FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
# more like 4.0.
# Note: Use(include) Slugged module in the config if using the anonymous module.
# If you have `friendly_id :name, use: slugged` in the model, Slugged module
# is included after the anonymous module defined in the initializer, so it
# overrides the `should_generate_new_friendly_id?` method from the anonymous module.
#
# config.use :slugged
# config.use Module.new {
# def should_generate_new_friendly_id?
# slug.blank? || <your_column_name_here>_changed?
# end
# }
#
# FriendlyId uses Rails's `parameterize` method to generate slugs, but for
# languages that don't use the Roman alphabet, that's not usually sufficient.
# Here we use the Babosa library to transliterate Russian Cyrillic slugs to
# ASCII. If you use this, don't forget to add "babosa" to your Gemfile.
#
# config.use Module.new {
# def normalize_friendly_id(text)
# text.to_slug.normalize! :transliterations => [:russian, :latin]
# end
# }
end
Loading
Loading