├── .dockerignore
├── .erb_lint.yml
├── .github
├── dependabot.yml
├── pull_request_template.md
└── workflows
│ ├── actionlint.yml
│ ├── ci.yml
│ ├── copy-pr-template-to-dependabot-prs.yml
│ ├── deploy.yml
│ └── release.yml
├── .gitignore
├── .govuk_dependabot_merger.yml
├── .rspec
├── .rubocop.yml
├── .ruby-version
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── LICENCE
├── README.md
├── Rakefile
├── app
├── assets
│ ├── builds
│ │ └── .keep
│ ├── config
│ │ └── manifest.js
│ ├── javascripts
│ │ ├── application.js
│ │ └── es6-components.js
│ └── stylesheets
│ │ ├── application.scss
│ │ └── components
│ │ ├── app-danger-block.scss
│ │ └── app-side.scss
├── controllers
│ ├── api_controller.rb
│ ├── application_controller.rb
│ ├── concerns
│ │ ├── link_filter_helper.rb
│ │ ├── link_importer_utils.rb
│ │ └── service_permissions.rb
│ ├── links_controller.rb
│ ├── local_authorities_controller.rb
│ ├── services_controller.rb
│ └── webhooks_controller.rb
├── helpers
│ └── application_helper.rb
├── lib
│ ├── google_analytics
│ │ ├── analytics_export_service.rb
│ │ ├── analytics_import_service.rb
│ │ ├── clicks_request.rb
│ │ ├── clicks_response.rb
│ │ ├── client.rb
│ │ └── google_analytics_export_credentials.rb
│ └── local_links_manager
│ │ ├── check_links
│ │ ├── link_status_requester.rb
│ │ └── link_status_updater.rb
│ │ ├── distributed_lock.rb
│ │ ├── export
│ │ ├── analytics_exporter.rb
│ │ ├── bad_links_url_and_status_exporter.rb
│ │ ├── link_status_exporter.rb
│ │ ├── links_exporter.rb
│ │ ├── local_authority_links_exporter.rb
│ │ └── service_links_exporter.rb
│ │ ├── import
│ │ ├── analytics_importer.rb
│ │ ├── csv_downloader.rb
│ │ ├── enabled_service_checker.rb
│ │ ├── error_message_formatter.rb
│ │ ├── errors.rb
│ │ ├── import_comparer.rb
│ │ ├── interactions_importer.rb
│ │ ├── links.rb
│ │ ├── local_authorities_importer.rb
│ │ ├── missing_links.rb
│ │ ├── processor.rb
│ │ ├── publishing_api_importer.rb
│ │ ├── response.rb
│ │ ├── service_interactions_importer.rb
│ │ ├── services_importer.rb
│ │ ├── services_tier_importer.rb
│ │ └── summariser.rb
│ │ └── link_resolver.rb
├── mailers
│ └── .keep
├── models
│ ├── .keep
│ ├── application_record.rb
│ ├── concerns
│ │ └── .keep
│ ├── interaction.rb
│ ├── link.rb
│ ├── local_authority.rb
│ ├── service.rb
│ ├── service_interaction.rb
│ ├── service_tier.rb
│ ├── tier.rb
│ └── user.rb
├── presenters
│ ├── interaction_presenter.rb
│ ├── link_api_response_presenter.rb
│ ├── link_presenter.rb
│ ├── links_table_presenter.rb
│ ├── local_authorities_table_presenter.rb
│ ├── local_authority_api_response_presenter.rb
│ ├── local_authority_external_content_presenter.rb
│ ├── local_authority_hash_presenter.rb
│ ├── local_authority_presenter.rb
│ ├── service_link_presenter.rb
│ ├── service_presenter.rb
│ ├── services_table_presenter.rb
│ └── url_status_presentation.rb
├── services
│ └── local_authority_external_content_publisher.rb
├── validators
│ └── non_blank_url_validator.rb
└── views
│ ├── layouts
│ └── application.html.erb
│ ├── links
│ ├── _link_status_alerts.html.erb
│ ├── _link_table_row.html.erb
│ ├── edit.html.erb
│ └── index.html.erb
│ ├── local_authorities
│ ├── _filter.html.erb
│ ├── download_links_form.html.erb
│ ├── edit_url.html.erb
│ ├── index.html.erb
│ ├── show.html.erb
│ └── upload_links_form.html.erb
│ ├── services
│ ├── _local_authority.html.erb
│ ├── _service_links_by_authority.html.erb
│ ├── download_links_form.html.erb
│ ├── index.html.erb
│ ├── show.html.erb
│ ├── update_owner_form.html.erb
│ └── upload_links_form.html.erb
│ └── shared
│ ├── _download_links_form.html.erb
│ ├── _flash.html.erb
│ ├── _links_table.html.erb
│ └── _upload_links_form.html.erb
├── bin
├── brakeman
├── dev
├── rails
├── rake
├── rubocop
├── setup
└── thrust
├── config.ru
├── config
├── application.rb
├── boot.rb
├── breadcrumbs.rb
├── database.yml
├── environment.rb
├── environments
│ ├── development.rb
│ ├── production.rb
│ └── test.rb
├── initializers
│ ├── application_controller_renderer.rb
│ ├── assets.rb
│ ├── backtrace_silencers.rb
│ ├── content_security_policy.rb
│ ├── cookies_serializer.rb
│ ├── filter_parameter_logging.rb
│ ├── govuk_publishing_components.rb
│ ├── inflections.rb
│ ├── mime_types.rb
│ ├── permissions_policy.rb
│ ├── prometheus.rb
│ ├── secrets_to_credentials.rb
│ ├── session_store.rb
│ └── wrap_parameters.rb
├── locales
│ └── en.yml
├── puma.rb
├── routes.rb
├── schedule.rb
├── secrets.yml
├── spring.rb
└── unicorn.rb
├── data
└── local-authorities.csv
├── db
├── migrate
│ ├── 20160413110438_create_users.rb
│ ├── 20160415102439_create_local_authorities.rb
│ ├── 20160427100154_create_interactions.rb
│ ├── 20160427110426_create_services.rb
│ ├── 20160428164235_add_indexes_to_interaction.rb
│ ├── 20160503101228_add_indexes_to_services.rb
│ ├── 20160504113456_create_service_interactions.rb
│ ├── 20160504133456_add_indexes_to_service_interactions.rb
│ ├── 20160511144329_add_tier_to_services.rb
│ ├── 20160513113110_add_slug_to_services.rb
│ ├── 20160517134617_create_links.rb
│ ├── 20160523155343_add_enabled_column_to_service.rb
│ ├── 20160525152805_add_slug_to_interactions.rb
│ ├── 20160615155529_add_index_to_local_authorities.rb
│ ├── 20160620155419_add_status_and_link_last_checked_to_links.rb
│ ├── 20160621090108_add_status_and_link_last_checked_to_local_authority.rb
│ ├── 20160729113356_add_parent_local_authority_id_to_local_authorities.rb
│ ├── 20161014123008_strip_urls.rb
│ ├── 20161104150651_add_broken_link_count_to_local_authority.rb
│ ├── 20161110122049_add_broken_link_count_to_service.rb
│ ├── 20161114122001_create_tier_and_service_tier.rb
│ ├── 20161116153011_delete_old_tier_columns.rb
│ ├── 20161212140755_add_created_at_to_service_tiers.rb
│ ├── 20170223163013_add_url_index_to_links.rb
│ ├── 20170405153230_add_linkerrors_and_linkwarnings_to_links.rb
│ ├── 20170406133715_add_link_errors_and_link_warnings_to_local_authority.rb
│ ├── 20170410075304_add_govuk_slug_and_title_to_service_interactions.rb
│ ├── 20170411084006_add_default_to_link_errors_and_link_warnings.rb
│ ├── 20170412223705_add_analytics_index_to_links.rb
│ ├── 20170703154357_change_link_errors_and_warnings_to_arrays.rb
│ ├── 20170703155105_add_problem_summary_and_suggested_fix_to_links.rb
│ ├── 20170713083505_add_index_on_local_authority_homepage_url.rb
│ ├── 20170926084949_allow_nil_urls.rb
│ ├── 20170928143923_add_status_index_to_links.rb
│ ├── 20190603131841_enable_service1788.rb
│ ├── 20190617101145_change_tiers1788.rb
│ ├── 20200406152345_add_index_to_service_tier.rb
│ ├── 20200415103445_enable_service1113.rb
│ ├── 20200501124957_enable_service_1287.rb
│ ├── 20200604142453_add_default_to_analytics.rb
│ ├── 20200605133220_add_constraint_to_analytics.rb
│ ├── 20201105085759_enable_service1826.rb
│ ├── 20201218121339_add_country_name_to_local_authority.rb
│ ├── 20211103173354_add_local_custodian_code_to_local_authority.rb
│ ├── 20220221141118_update_leicestershire_street_parties_permission.rb
│ ├── 20230206155322_add_active_end_date_and_active_note_to_local_authority.rb
│ ├── 20230307103144_add_succeeded_by_local_authority_id_to_local_authority.rb
│ ├── 20230504115751_allow_null_snac.rb
│ ├── 20240318105241_add_content_id_to_local_authorities.rb
│ ├── 20240730142812_modify_services_add_organisation_slug.rb
│ └── 20250424092221_add_title_to_links.rb
├── schema.rb
└── seeds.rb
├── docs
├── adr
│ └── adr-001-department-permissions.md
├── checking-links.md
├── deleting-a-link.md
├── enable-or-create-service.md
├── example-api-output.md
├── exporting-local-authority-links.md
├── google_analytics_custom_import.md
├── importing-local-authorities-data.md
├── importing-service-links-from-csv.md
├── local-authority-changes.md
├── permissions.md
├── remove-a-service.md
└── service-owners.md
├── lgsl_lgil_fallback_links.csv
├── lib
├── assets
│ └── .keep
└── tasks
│ ├── .keep
│ ├── check-links
│ └── link_checker.rake
│ ├── export
│ ├── analytics_bad_links_exporter.rake
│ ├── blank_csv_exporter.rake
│ ├── link_exporter.rake
│ └── links_status_csv_exporter.rake
│ ├── import
│ ├── all.rake
│ ├── google_analytics.rake
│ ├── local_authorities.rake
│ ├── missing_links.rake
│ ├── service_interactions.rake
│ └── service_links_from_csv.rake
│ ├── lint.rake
│ ├── remove_lock.rake
│ └── service.rake
├── log
└── .keep
├── package.json
├── public
├── 404.html
├── 422.html
├── 500.html
├── data
│ └── .keep
├── favicon.ico
└── robots.txt
├── spec
├── data
│ └── local_authorities_csv_spec.rb
├── factories
│ ├── clicks_response_factory.rb
│ ├── interactions.rb
│ ├── links.rb
│ ├── local_authorities.rb
│ ├── service_interactions.rb
│ ├── services.rb
│ └── users.rb
├── features
│ ├── links
│ │ ├── index_spec.rb
│ │ └── links_spec.rb
│ ├── local_authorities
│ │ ├── local_authority_download_links_spec.rb
│ │ ├── local_authority_index_spec.rb
│ │ ├── local_authority_show_spec.rb
│ │ └── local_authority_upload_links_spec.rb
│ └── services
│ │ ├── service_index_spec.rb
│ │ └── service_show_spec.rb
├── fixtures
│ └── service-links.csv
├── helpers
│ └── application_helper_spec.rb
├── lib
│ ├── google_analytics
│ │ ├── analytics_export_service_spec.rb
│ │ ├── analytics_import_service_spec.rb
│ │ ├── clicks_request_spec.rb
│ │ ├── clicks_response_spec.rb
│ │ └── client_spec.rb
│ ├── local-links-manager
│ │ ├── check_links
│ │ │ └── link_status_requester_spec.rb
│ │ ├── export
│ │ │ ├── analytics_exporter_spec.rb
│ │ │ ├── bad_links_url_and_status_exporter_spec.rb
│ │ │ ├── fixtures
│ │ │ │ ├── bad_links_url_status.csv
│ │ │ │ ├── exported_links.csv
│ │ │ │ └── ni_link.csv
│ │ │ ├── links_status_exporter_spec.rb
│ │ │ ├── local_authority_links_exporter_spec.rb
│ │ │ └── service_links_exporter_spec.rb
│ │ ├── import
│ │ │ ├── analytics_importer_spec.rb
│ │ │ ├── csv_downloader_spec.rb
│ │ │ ├── enabled_service_checker_spec.rb
│ │ │ ├── fixtures
│ │ │ │ ├── imported_links.csv
│ │ │ │ ├── imported_links_all_errors.csv
│ │ │ │ ├── imported_links_few_errors.csv
│ │ │ │ ├── imported_links_many_errors.csv
│ │ │ │ ├── imported_links_nothing_to_import.csv
│ │ │ │ ├── local-authorities.csv
│ │ │ │ ├── local_contacts_sample.csv
│ │ │ │ ├── sample.csv
│ │ │ │ └── sample_malformed.csv
│ │ │ ├── import_comparer_spec.rb
│ │ │ ├── interactions_importer_spec.rb
│ │ │ ├── links_spec.rb
│ │ │ ├── local_authorities_importer_spec.rb
│ │ │ ├── missing_links_spec.rb
│ │ │ ├── publishing_api_importer_spec.rb
│ │ │ ├── service_interactions_importer_spec.rb
│ │ │ ├── services_importer_spec.rb
│ │ │ └── services_tier_importer_spec.rb
│ │ └── link_resolver_spec.rb
│ └── tasks
│ │ ├── export
│ │ ├── blank_csv_exporter_spec.rb
│ │ ├── link_exporter_spec.rb
│ │ └── links_status_csv_exporter_spec.rb
│ │ ├── import
│ │ └── service_links_from_csv_spec.rb
│ │ └── service_spec.rb
├── models
│ ├── interaction_spec.rb
│ ├── link_spec.rb
│ ├── local_authority_spec.rb
│ ├── service_interaction_spec.rb
│ ├── service_spec.rb
│ └── user_spec.rb
├── presenters
│ ├── interaction_presenter_spec.rb
│ ├── link_api_response_presenter_spec.rb
│ ├── link_presenter_spec.rb
│ ├── local_authority_api_response_presenter_spec.rb
│ ├── local_authority_external_content_presenter_spec.rb
│ ├── local_authority_hash_presenter_spec.rb
│ ├── local_authority_presenter_spec.rb
│ ├── service_link_presenter_spec.rb
│ └── url_status_presentation_spec.rb
├── rails_helper.rb
├── requests
│ ├── api
│ │ ├── link_spec.rb
│ │ └── local_authority_spec.rb
│ ├── bad_homepage_csv_spec.rb
│ ├── broken_links_page_spec.rb
│ ├── council_page_spec.rb
│ ├── edit_link_page_spec.rb
│ ├── link_checker_api_spec.rb
│ ├── link_status_csv_spec.rb
│ └── services_page_spec.rb
├── services
│ └── local_authority_external_content_publisher_spec.rb
├── spec_helper.rb
├── support
│ ├── appear_before_matcher.rb
│ ├── authentication.rb
│ ├── factory_bot.rb
│ ├── gds_api_adapters.rb
│ ├── shoulda.rb
│ ├── stub_csv_rows.rb
│ ├── timecop.rb
│ ├── url_status_presentation.rb
│ └── webmock.rb
├── system
│ ├── edit_link_page_spec.rb
│ ├── main_menu_spec.rb
│ ├── service_page_spec.rb
│ ├── services_page_spec.rb
│ └── upload_links_csv_spec.rb
├── tasks
│ └── missing_links_spec.rb
└── validators
│ └── non_blank_url_validator_spec.rb
├── vendor
└── assets
│ └── stylesheets
│ └── .keep
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | .dockerignore
2 | .git
3 | .gitignore
4 | .github
5 | Dockerfile
6 | README.md
7 | coverage
8 | docs
9 | log
10 | node_modules
11 | spec
12 | test
13 | tmp
14 | vendor
15 |
--------------------------------------------------------------------------------
/.erb_lint.yml:
--------------------------------------------------------------------------------
1 | ---
2 | glob: "**/*.erb"
3 | exclude:
4 | - '**/vendor/**/*'
5 | - 'test/fixtures/**'
6 | - "app/views/admin/find_in_admin_bookmarklet/_bookmarklet.erb"
7 | linters:
8 | Rubocop:
9 | enabled: true
10 | exclude:
11 | - "**/vendor/**/*"
12 | - "**/vendor/**/.*"
13 | - "bin/**"
14 | - "db/**/*"
15 | - "config/**/*"
16 | rubocop_config:
17 | inherit_from:
18 | - .rubocop.yml
19 | AllCops:
20 | DisabledByDefault: true
21 | Layout/InitialIndentation:
22 | Enabled: false
23 | Layout/TrailingEmptyLines:
24 | Enabled: false
25 | Layout/TrailingWhitespace:
26 | Enabled: false
27 | Naming/FileName:
28 | Enabled: false
29 | Style/FrozenStringLiteralComment:
30 | Enabled: false
31 | Layout/LineLength:
32 | Enabled: false
33 | Lint/UselessAssignment:
34 | Enabled: false
35 | Layout/FirstHashElementIndentation:
36 | Enabled: false
37 | Rails/SaveBang:
38 | Enabled: false
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: bundler
4 | directory: /
5 | schedule:
6 | interval: daily
7 |
8 | - package-ecosystem: npm
9 | directory: /
10 | schedule:
11 | interval: daily
12 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ⚠️ This repo is Continuously Deployed: make sure you [follow the guidance](https://docs.publishing.service.gov.uk/manual/development-pipeline.html#merge-your-own-pull-request) ⚠️
2 |
3 | Follow [these steps](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html) if you are doing a Rails upgrade.
4 |
--------------------------------------------------------------------------------
/.github/workflows/actionlint.yml:
--------------------------------------------------------------------------------
1 | name: Lint GitHub Actions
2 | on:
3 | push:
4 | paths: ['.github/**']
5 | jobs:
6 | actionlint:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | with:
11 | show-progress: false
12 | - uses: alphagov/govuk-infrastructure/.github/actions/actionlint@main
13 |
--------------------------------------------------------------------------------
/.github/workflows/copy-pr-template-to-dependabot-prs.yml:
--------------------------------------------------------------------------------
1 | name: Copy PR template to Dependabot PRs
2 |
3 | on:
4 | pull_request_target:
5 | types: [opened]
6 |
7 | permissions:
8 | contents: read
9 | pull-requests: write
10 |
11 | jobs:
12 | copy_pr_template:
13 | name: Copy PR template to Dependabot PR
14 | runs-on: ubuntu-latest
15 | if: github.actor == 'dependabot[bot]'
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - name: Post PR template as a comment
20 | uses: actions/github-script@v7
21 | with:
22 | script: |
23 | const fs = require('fs')
24 |
25 | const body = [
26 | "pull_request_template.md",
27 | ".github/pull_request_template.md",
28 | "docs/pull_request_template.md",
29 | ].
30 | filter(path => fs.existsSync(path)).
31 | map(path => fs.readFileSync(path)).
32 | join("\n")
33 |
34 | if (body !== "") {
35 | github.rest.issues.createComment({
36 | issue_number: context.issue.number,
37 | owner: context.repo.owner,
38 | repo: context.repo.repo,
39 | body
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | run-name: Deploy ${{ inputs.gitRef || github.event.release.tag_name }} to ${{ inputs.environment || 'integration' }}
4 |
5 | on:
6 | workflow_dispatch:
7 | inputs:
8 | gitRef:
9 | description: 'Commit, tag or branch name to deploy'
10 | required: true
11 | type: string
12 | environment:
13 | description: 'Environment to deploy to'
14 | required: true
15 | type: choice
16 | options:
17 | - integration
18 | - staging
19 | - production
20 | default: 'integration'
21 | release:
22 | types: [released]
23 |
24 | jobs:
25 | build-and-publish-image:
26 | if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'v')
27 | name: Build and publish image
28 | uses: alphagov/govuk-infrastructure/.github/workflows/build-and-push-multiarch-image.yml@main
29 | with:
30 | gitRef: ${{ inputs.gitRef || github.event.release.tag_name }}
31 | permissions:
32 | id-token: write
33 | contents: read
34 | packages: write
35 | trigger-deploy:
36 | name: Trigger deploy to ${{ inputs.environment || 'integration' }}
37 | needs: build-and-publish-image
38 | uses: alphagov/govuk-infrastructure/.github/workflows/deploy.yml@main
39 | with:
40 | imageTag: ${{ needs.build-and-publish-image.outputs.imageTag }}
41 | environment: ${{ inputs.environment || 'integration' }}
42 | secrets:
43 | WEBHOOK_TOKEN: ${{ secrets.GOVUK_ARGO_EVENTS_WEBHOOK_TOKEN }}
44 | WEBHOOK_URL: ${{ secrets.GOVUK_ARGO_EVENTS_WEBHOOK_URL }}
45 | GH_TOKEN: ${{ secrets.GOVUK_CI_GITHUB_API_TOKEN }}
46 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | workflow_run:
6 | workflows: [CI]
7 | types: [completed]
8 | branches: [main]
9 |
10 | jobs:
11 | release:
12 | if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
13 | name: Release
14 | uses: alphagov/govuk-infrastructure/.github/workflows/release.yml@main
15 | secrets:
16 | GH_TOKEN: ${{ secrets.GOVUK_CI_GITHUB_API_TOKEN }}
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile '~/.gitignore_global'
6 |
7 | # Ignore bundler config.
8 | /.bundle
9 |
10 | # Ignore the default SQLite database.
11 | /db/*.sqlite3
12 | /db/*.sqlite3-journal
13 |
14 | # Ignore all logfiles and tempfiles.
15 | /log/*
16 | !/log/.keep
17 | /tmp
18 | /coverage
19 |
20 | # Ignore asset build dirs
21 | /app/assets/builds/*
22 | !/app/assets/builds/.keep
23 | public/assets/*
24 |
25 | # Ignore /data, 'cause that's where we're storing the data exports.
26 | public/data/*
27 | !public/data/.keep
28 |
29 | .DS_Store
30 |
31 | # ignore dev credentials for Google analytics
32 | /config/local_env.yml
33 |
34 | node_modules
35 | yarn-error.log
36 |
--------------------------------------------------------------------------------
/.govuk_dependabot_merger.yml:
--------------------------------------------------------------------------------
1 | api_version: 2
2 | defaults:
3 | auto_merge: true
4 | update_external_dependencies: true
5 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --require rails_helper
3 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | inherit_gem:
2 | rubocop-govuk:
3 | - config/default.yml
4 | - config/rails.yml
5 |
6 | inherit_mode:
7 | merge:
8 | - Exclude
9 |
10 | # **************************************************************
11 | # TRY NOT TO ADD OVERRIDES IN THIS FILE
12 | #
13 | # This repo is configured to follow the RuboCop GOV.UK styleguide.
14 | # Any rules you override here will cause this repo to diverge from
15 | # the way we write code in all other GOV.UK repos.
16 | #
17 | # See https://github.com/alphagov/rubocop-govuk/blob/main/CONTRIBUTING.md
18 | # **************************************************************
19 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.3.1
2 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG ruby_version=3.3
2 | ARG base_image=ghcr.io/alphagov/govuk-ruby-base:$ruby_version
3 | ARG builder_image=ghcr.io/alphagov/govuk-ruby-builder:$ruby_version
4 |
5 |
6 | FROM --platform=$TARGETPLATFORM $builder_image AS builder
7 |
8 | WORKDIR $APP_HOME
9 | COPY Gemfile* .ruby-version ./
10 | RUN bundle install
11 | COPY . .
12 | RUN bootsnap precompile --gemfile .
13 | RUN rails assets:precompile && rm -fr log
14 |
15 |
16 | FROM --platform=$TARGETPLATFORM $base_image
17 |
18 | ENV GOVUK_APP_NAME=local-links-manager
19 |
20 | WORKDIR $APP_HOME
21 | COPY --from=builder $BUNDLE_PATH $BUNDLE_PATH
22 | COPY --from=builder $BOOTSNAP_CACHE_DIR $BOOTSNAP_CACHE_DIR
23 | COPY --from=builder $APP_HOME .
24 |
25 | USER app
26 | CMD ["puma"]
27 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | ruby "~> 3.3.1"
4 |
5 | gem "rails", "8.0.2"
6 |
7 | gem "addressable"
8 | gem "aws-sdk-s3"
9 | gem "bootsnap", require: false
10 | gem "dalli"
11 | gem "dartsass-rails"
12 | gem "gds-api-adapters"
13 | gem "gds-sso"
14 | gem "google-api-client"
15 | gem "googleauth"
16 | gem "govuk_app_config"
17 | gem "govuk_publishing_components"
18 | gem "gretel"
19 | gem "jbuilder"
20 | gem "mlanett-redis-lock"
21 | gem "pg"
22 | gem "plek"
23 | gem "redis"
24 | gem "rubocop-govuk"
25 | gem "sprockets-rails"
26 | gem "whenever", require: false
27 |
28 | group :development do
29 | gem "better_errors"
30 | gem "capistrano-rails"
31 | gem "web-console" # Access an IRB console by using <%= console %> in views
32 | end
33 |
34 | group :development, :test do
35 | gem "erb_lint", require: false
36 | gem "factory_bot_rails"
37 | gem "pry-byebug"
38 | gem "rspec-rails"
39 | gem "shoulda-matchers"
40 | gem "simplecov", require: false
41 | end
42 |
43 | group :test do
44 | gem "capybara"
45 | gem "govuk_test"
46 | gem "timecop"
47 | gem "webmock"
48 | end
49 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Crown Copyright (Government Digital Service)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require File.expand_path("config/application", __dir__)
5 |
6 | Rails.application.load_tasks
7 |
8 | # RSpec shoves itself into the default task without asking, which confuses the ordering.
9 | # https://github.com/rspec/rspec-rails/blob/eb3377bca425f0d74b9f510dbb53b2a161080016/lib/rspec/rails/tasks/rspec.rake#L6
10 | Rake::Task[:default].clear if Rake::Task.task_defined?(:default)
11 | task default: %i[lint spec]
12 |
--------------------------------------------------------------------------------
/app/assets/builds/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/app/assets/builds/.keep
--------------------------------------------------------------------------------
/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link application.js
2 | //= link es6-components.js
3 | //= link_tree ../builds
4 |
--------------------------------------------------------------------------------
/app/assets/javascripts/application.js:
--------------------------------------------------------------------------------
1 | //= require govuk_publishing_components/dependencies
2 | //= require govuk_publishing_components/lib
3 | //= require govuk_publishing_components/components/option-select
4 | //= require govuk_publishing_components/components/table
5 |
--------------------------------------------------------------------------------
/app/assets/javascripts/es6-components.js:
--------------------------------------------------------------------------------
1 | // These modules from govuk_publishing_components
2 | // depend on govuk-frontend modules. govuk-frontend
3 | // now targets browsers that support `type="module"`.
4 | //
5 | // To gracefully prevent execution of these scripts
6 | // on browsers that don't support ES6, this script
7 | // should be included in a `type="module"` script tag
8 | // which will ensure they are never loaded.
9 |
10 | //= require govuk_publishing_components/components/layout-header
11 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/application.scss:
--------------------------------------------------------------------------------
1 | // This is a publishing app, so we can use a more generous page width
2 | $govuk-page-width: 1140px;
3 |
4 | // supporting sass
5 | @import "govuk_publishing_components/govuk_frontend_support";
6 | @import "govuk_publishing_components/component_support";
7 | // component specific sass
8 | @import "govuk_publishing_components/components/breadcrumbs";
9 | @import "govuk_publishing_components/components/button";
10 | @import "govuk_publishing_components/components/checkboxes";
11 | @import "govuk_publishing_components/components/error-alert";
12 | @import "govuk_publishing_components/components/file-upload";
13 | @import "govuk_publishing_components/components/heading";
14 | @import "govuk_publishing_components/components/input";
15 | @import "govuk_publishing_components/components/inset-text";
16 | @import "govuk_publishing_components/components/layout-footer";
17 | @import "govuk_publishing_components/components/layout-for-admin";
18 | @import "govuk_publishing_components/components/layout-header";
19 | @import "govuk_publishing_components/components/notice";
20 | @import "govuk_publishing_components/components/option-select";
21 | @import "govuk_publishing_components/components/table";
22 | @import "govuk_publishing_components/components/success-alert";
23 | @import "govuk_publishing_components/components/summary-list";
24 |
25 | // app specific sass
26 | @import "components/app-danger-block";
27 | @import "components/app-side";
28 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/components/app-danger-block.scss:
--------------------------------------------------------------------------------
1 | .app-danger-block {
2 | @include govuk-responsive-margin(6, "top");
3 | }
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/components/app-side.scss:
--------------------------------------------------------------------------------
1 | .app-side {
2 | padding: govuk-spacing(3);
3 | background: govuk-colour("light-grey");
4 | }
5 |
--------------------------------------------------------------------------------
/app/controllers/api_controller.rb:
--------------------------------------------------------------------------------
1 | class ApiController < ApplicationController
2 | skip_before_action :authenticate_user!
3 |
4 | def link
5 | return render json: {}, status: :bad_request if missing_required_params_for_link?
6 | return render json: {}, status: :not_found if missing_objects_for_link?
7 |
8 | link = LocalLinksManager::LinkResolver.new(authority, service, interaction).resolve
9 |
10 | render json: LinkApiResponsePresenter.new(authority, link).present
11 | end
12 |
13 | def local_authority
14 | return render json: {}, status: :bad_request if missing_required_params_for_local_authority?
15 | return render json: {}, status: :not_found if missing_objects_for_local_authority?
16 |
17 | render json: LocalAuthorityApiResponsePresenter.new(authority).present
18 | end
19 |
20 | private
21 |
22 | def missing_required_params_for_link?
23 | missing_authority_identity? || conflicting_authority_identity? || params[:lgsl].blank?
24 | end
25 |
26 | def missing_required_params_for_local_authority?
27 | missing_authority_identity? || conflicting_authority_identity?
28 | end
29 |
30 | def missing_authority_identity?
31 | params[:authority_slug].blank? && params[:local_custodian_code].blank?
32 | end
33 |
34 | def conflicting_authority_identity?
35 | params[:authority_slug].present? && params[:local_custodian_code].present?
36 | end
37 |
38 | def missing_objects_for_link?
39 | authority.nil? || service.nil?
40 | end
41 |
42 | def missing_objects_for_local_authority?
43 | authority.nil?
44 | end
45 |
46 | def authority
47 | @authority ||= if params[:authority_slug]
48 | LocalAuthority.find_current_by_slug(params[:authority_slug])
49 | elsif params[:local_custodian_code]
50 | LocalAuthority.find_current_by_local_custodian_code(params[:local_custodian_code])
51 | end
52 | end
53 |
54 | def service
55 | @service ||= Service.find_by(lgsl_code: params[:lgsl])
56 | end
57 |
58 | def interaction
59 | @interaction ||= Interaction.find_by(lgil_code: params[:lgil])
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | include GDS::SSO::ControllerMethods
3 | include ServicePermissions
4 |
5 | helper_method :gds_editor?
6 |
7 | before_action :authenticate_user!
8 | # Prevent CSRF attacks by raising an exception.
9 | # For APIs, you may want to use :null_session instead.
10 | protect_from_forgery with: :exception
11 | end
12 |
--------------------------------------------------------------------------------
/app/controllers/concerns/link_filter_helper.rb:
--------------------------------------------------------------------------------
1 | module LinkFilterHelper
2 | def set_filter_var
3 | @filter_var = params[:filter] == "broken_links" ? nil : "broken_links"
4 | end
5 |
6 | def filtered_links(links)
7 | case params[:filter]
8 | when "broken_links"
9 | links.broken_or_missing
10 | else
11 | links
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/controllers/concerns/link_importer_utils.rb:
--------------------------------------------------------------------------------
1 | module LinkImporterUtils
2 | def clear_errors_from_links_importer(links_importer)
3 | if links_importer.errors.count == links_importer.total_rows
4 | "Errors on all lines. Ensure a New URL column exists, with all rows either blank or a valid URL"
5 | elsif links_importer.errors.count > 50
6 | errors = links_importer.errors.first(50).map { |e| line_number_from_error(e) }
7 | ["#{links_importer.errors.count} Errors detected. Please ensure a valid entry in the New URL column for lines (showing first 50):"] + errors
8 | else
9 | errors = links_importer.errors.map { |e| line_number_from_error(e) }
10 | ["#{links_importer.errors.count} Errors detected. Please ensure a valid entry in the New URL column for lines:"] + errors
11 | end
12 | end
13 |
14 | def line_number_from_error(error)
15 | match_element = /\ALine (\d+): invalid URL/.match(error)
16 | match_element[1]
17 | end
18 |
19 | def attempt_import(type, object)
20 | if params[:csv]
21 | links_importer = LocalLinksManager::Import::Links.new(type:, object:)
22 | update_count = links_importer.import_links(params[:csv].read)
23 | if links_importer.errors.any?
24 | flash[:danger] = clear_errors_from_links_importer(links_importer)
25 | false
26 | elsif update_count.zero?
27 | flash[:info] = "No records updated. (If you were expecting updates, check the format of the uploaded file)"
28 | false
29 | else
30 | flash[:success] = "#{update_count} #{'link has'.pluralize(update_count)} been updated"
31 | true
32 | end
33 | else
34 | flash[:danger] = "A CSV file must be provided."
35 | false
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/app/controllers/concerns/service_permissions.rb:
--------------------------------------------------------------------------------
1 | module ServicePermissions
2 | def gds_editor?
3 | current_user.permissions.include?("GDS Editor")
4 | end
5 |
6 | def service_owner?(service)
7 | service.organisation_slugs.include?(current_user.organisation_slug)
8 | end
9 |
10 | def permission_for_service?(service)
11 | gds_editor? || service_owner?(service)
12 | end
13 |
14 | def org_name_for_current_user
15 | GdsApi.organisations.organisation(current_user.organisation_slug).to_hash["title"]
16 | rescue GdsApi::HTTPUnavailable
17 | current_user.organisation_slug
18 | end
19 |
20 | def redirect_unless_gds_editor
21 | redirect_to services_path unless gds_editor?
22 | end
23 |
24 | def forbid_unless_permission
25 | raise GDS::SSO::PermissionDeniedError, "You do not have permission to view this page" unless permission_for_service?(@service)
26 | end
27 |
28 | def forbid_unless_gds_editor
29 | raise GDS::SSO::PermissionDeniedError, "You do not have permission to view this page" unless gds_editor?
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/controllers/webhooks_controller.rb:
--------------------------------------------------------------------------------
1 | require "gds_api/link_checker_api"
2 |
3 | class WebhooksController < ApplicationController
4 | skip_before_action :authenticate_user!
5 | skip_before_action :verify_authenticity_token
6 | before_action :verify_signature
7 |
8 | def link_check_callback
9 | LocalLinksManager::CheckLinks::LinkStatusUpdater.new.call(
10 | GdsApi::LinkCheckerApi::BatchReport.new(
11 | params.to_unsafe_hash,
12 | ),
13 | )
14 | end
15 |
16 | private
17 |
18 | def verify_signature
19 | return unless Rails.application.credentials.link_checker_api_secret_token
20 |
21 | given_signature = request.headers["X-LinkCheckerApi-Signature"]
22 | return head :bad_request unless given_signature
23 |
24 | body = request.raw_post
25 | signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), webhook_secret_token, body)
26 | head :bad_request unless Rack::Utils.secure_compare(signature, given_signature)
27 | end
28 |
29 | def webhook_secret_token
30 | Rails.application.credentials.link_checker_api_secret_token
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | # Used to set a class onto an element containing a count.
3 | # Enables the use of the :after CSS pseudo-class to set
4 | # e.g. content: 'many' or content: 'one' on the element.
5 | # This helps solve the problem of the govuk_admin_template table filter
6 | # filtering rows based on the words 'Broken Link(s)' on the
7 | # Local Authority + Services index pages.
8 | def singular_or_plural(num)
9 | num == 1 ? "singular" : "plural"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/lib/google_analytics/analytics_import_service.rb:
--------------------------------------------------------------------------------
1 | module GoogleAnalytics
2 | class AnalyticsImportService
3 | def self.activity
4 | new.activity
5 | end
6 |
7 | def client
8 | @client ||= Client.new.build
9 | end
10 |
11 | def activity
12 | request = ClicksRequest.new.build
13 |
14 | response = client.batch_get_reports(request)
15 | ClicksResponse.new.parse(response)
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/lib/google_analytics/clicks_response.rb:
--------------------------------------------------------------------------------
1 | require "google/apis/analyticsreporting_v4"
2 |
3 | module GoogleAnalytics
4 | class ClicksResponse
5 | include Google::Apis::AnalyticsreportingV4
6 |
7 | def parse(response)
8 | report = response.reports.first
9 | report.data.rows.map do |row|
10 | {
11 | base_path: row.dimensions.first,
12 | local_link: row.dimensions.second,
13 | clicks: row.metrics.first.values.first.to_i,
14 | }
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/lib/google_analytics/client.rb:
--------------------------------------------------------------------------------
1 | require "google/apis/analyticsreporting_v4"
2 | require "googleauth"
3 |
4 | module GoogleAnalytics
5 | class Client
6 | include Google::Apis::AnalyticsreportingV4
7 | include Google::Auth
8 |
9 | def build(scope: "https://www.googleapis.com/auth/analytics.readonly")
10 | @client ||= AnalyticsReportingService.new
11 | @client.authorization ||= ServiceAccountCredentials.make_creds(scope:)
12 | @client
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/lib/google_analytics/google_analytics_export_credentials.rb:
--------------------------------------------------------------------------------
1 | require "googleauth"
2 |
3 | module GoogleAnalytics
4 | class GoogleAnalyticsExportCredentials
5 | def self.authorization(scopes)
6 | ENV["GOOGLE_ACCOUNT_TYPE"] = "service_account"
7 | raise ArgumentError, "Must define GOOGLE_PRIVATE_KEY and GOOGLE_CLIENT_EMAIL in order to authenticate." unless all_configuration_in_env?
8 |
9 | Google::Auth.get_application_default(scopes)
10 | end
11 |
12 | def self.all_configuration_in_env?
13 | %w[GOOGLE_PRIVATE_KEY GOOGLE_CLIENT_EMAIL].all? { |env_var| ENV[env_var].present? }
14 | end
15 | private_class_method :all_configuration_in_env?
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/check_links/link_status_requester.rb:
--------------------------------------------------------------------------------
1 | require "gds_api/link_checker_api"
2 |
3 | module LocalLinksManager
4 | module CheckLinks
5 | class LinkStatusRequester
6 | delegate :url_helpers, to: "Rails.application.routes"
7 |
8 | def call
9 | ServiceInteraction.includes(:service)
10 | .where(services: { enabled: true })
11 | .find_each do |service|
12 | urls = service.links.with_url.order(analytics: :asc).map(&:url).uniq
13 | check_urls(urls) unless urls.empty?
14 | end
15 |
16 | check_urls homepage_urls
17 | end
18 |
19 | def check_authority_urls(authority_slug)
20 | check_urls urls_for_authority(authority_slug).uniq
21 | end
22 |
23 | private
24 |
25 | def urls_for_authority(authority_slug)
26 | local_authority = LocalAuthority.find_by(slug: authority_slug)
27 | local_authority.links.with_url.map(&:url) << local_authority.homepage_url
28 | end
29 |
30 | def homepage_urls
31 | LocalAuthority.all.map(&:homepage_url).sort
32 | end
33 |
34 | def check_urls(urls)
35 | link_checker_api.create_batch(
36 | urls,
37 | webhook_uri:,
38 | webhook_secret_token:,
39 | )
40 | end
41 |
42 | def webhook_uri
43 | Plek.find("local-links-manager") + url_helpers.link_checker_webhook_path
44 | end
45 |
46 | def webhook_secret_token
47 | Rails.application.credentials.link_checker_api_secret_token
48 | end
49 |
50 | def link_checker_api
51 | @link_checker_api ||= GdsApi::LinkCheckerApi.new(
52 | link_checker_api_url,
53 | bearer_token: ENV["LINK_CHECKER_API_BEARER_TOKEN"],
54 | timeout: 10,
55 | )
56 | end
57 |
58 | def link_checker_api_url
59 | Plek.find("link-checker-api")
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/check_links/link_status_updater.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module CheckLinks
3 | class LinkStatusUpdater
4 | def call(batch_report)
5 | batch_report.links.each do |link_report|
6 | update_link(link_report)
7 | register_authority_and_service_for_update(link_report.uri)
8 | end
9 |
10 | update_broken_link_counts
11 | end
12 |
13 | private
14 |
15 | def register_authority_and_service_for_update(url)
16 | Link.where(url:).find_each do |link|
17 | local_authority_ids.add(link.local_authority.id)
18 | service_ids.add(link.service.id)
19 | end
20 | end
21 |
22 | def update_broken_link_counts
23 | LocalAuthority.where(id: local_authority_ids.to_a)
24 | .find_each(&:update_broken_link_count)
25 |
26 | Service.where(id: service_ids.to_a)
27 | .find_each(&:update_broken_link_count)
28 | end
29 |
30 | def update_link(link_report)
31 | fields = link_report_fields(link_report)
32 |
33 | Link
34 | .where(url: link_report.uri)
35 | .last_checked_before(link_report.checked)
36 | .update_all(fields)
37 |
38 | LocalAuthority
39 | .where(homepage_url: link_report.uri)
40 | .link_last_checked_before(link_report.checked)
41 | .update_all(fields)
42 | end
43 |
44 | def link_report_fields(link_report)
45 | {
46 | status: link_report.status,
47 | link_errors: link_report.errors,
48 | link_warnings: link_report.warnings,
49 | link_last_checked: link_report.checked,
50 | problem_summary: link_report.problem_summary,
51 | suggested_fix: link_report.suggested_fix,
52 | }
53 | end
54 |
55 | def local_authority_ids
56 | @local_authority_ids ||= Set.new
57 | end
58 |
59 | def service_ids
60 | @service_ids ||= Set.new
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/distributed_lock.rb:
--------------------------------------------------------------------------------
1 | require "redis-lock"
2 |
3 | module LocalLinksManager
4 | class DistributedLock
5 | attr_accessor :lock_name, :redis_lock
6 |
7 | APP = "local-links-manager".freeze
8 | LIFETIME = (60 * 60) # seconds
9 |
10 | def initialize(lock_name)
11 | @lock_name = lock_name
12 | @redis_lock = Redis::Lock.new(Redis.new, "#{APP}:#{lock_name}", owner: APP, life: LIFETIME)
13 | end
14 |
15 | def lock(lock_obtained:, lock_not_obtained:)
16 | redis_lock.lock
17 | Rails.logger.debug("Successfully got a lock. Running...")
18 | lock_obtained.call
19 | rescue Redis::Lock::LockNotAcquired => e
20 | Rails.logger.debug("Failed to get lock for #{lock_name} (#{e.message}). Another process probably got there first.")
21 | lock_not_obtained.call
22 | end
23 |
24 | def unlock
25 | redis_lock.unlock
26 | Rails.logger.debug("Successfully unlocked #{lock_name}")
27 | rescue StandardError => e
28 | Rails.logger.error("Failed to unlock #{lock_name}\n#{e.message}")
29 | end
30 |
31 | delegate :locked?, to: :redis_lock
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/export/analytics_exporter.rb:
--------------------------------------------------------------------------------
1 | require_relative "bad_links_url_and_status_exporter"
2 | require_relative "../../google_analytics/analytics_export_service"
3 |
4 | module LocalLinksManager
5 | module Export
6 | class AnalyticsExporter
7 | attr_reader :client
8 |
9 | def initialize
10 | @client = GoogleAnalytics::AnalyticsExportService.new
11 | @service = @client.build
12 | end
13 |
14 | def self.export
15 | new.export_bad_links
16 | end
17 |
18 | def bad_links_data
19 | LocalLinksManager::Export::BadLinksUrlAndStatusExporter.bad_links_url_and_status_csv(with_ga_headings: true)
20 | end
21 |
22 | def export_bad_links
23 | client.export_bad_links(bad_links_data)
24 | rescue StandardError => e
25 | logger.error "The export has failed with the following error: #{e.message}"
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/export/bad_links_url_and_status_exporter.rb:
--------------------------------------------------------------------------------
1 | require "csv"
2 |
3 | module LocalLinksManager
4 | module Export
5 | class BadLinksUrlAndStatusExporter
6 | HEADINGS = %w[url status].freeze
7 | GA_HEADINGS = %w[ga:dimension36 ga:dimension37].freeze
8 |
9 | def self.local_authority_bad_homepage_url_and_status_csv
10 | CSV.generate do |csv|
11 | csv << HEADINGS
12 | LocalAuthority.where(status: "broken").distinct.pluck(:homepage_url, :problem_summary).each do |row|
13 | csv << row
14 | end
15 | end
16 | end
17 |
18 | def self.bad_links_url_and_status_csv(with_ga_headings: false)
19 | CSV.generate do |csv|
20 | csv << (with_ga_headings ? GA_HEADINGS : HEADINGS)
21 | Link.enabled_links.broken.distinct.pluck(:url, :problem_summary).each do |row|
22 | csv << row
23 | end
24 | end
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/export/link_status_exporter.rb:
--------------------------------------------------------------------------------
1 | require "csv"
2 |
3 | module LocalLinksManager
4 | module Export
5 | class LinkStatusExporter
6 | HEADINGS = %w[problem_summary count status].freeze
7 |
8 | def self.homepage_links_status_csv
9 | CSV.generate do |csv|
10 | csv << HEADINGS
11 | LocalAuthority.group(:problem_summary, :status).count.each do |(problem_summary, status), count|
12 | csv << [problem_summary || "nil", count, status || "nil"]
13 | end
14 | end
15 | end
16 |
17 | def self.links_status_csv
18 | CSV.generate do |csv|
19 | csv << HEADINGS
20 | Link.with_url.enabled_links.group(:problem_summary, :status).count.each do |(problem_summary, status), count|
21 | csv << [problem_summary || "nil", count, status || "nil"]
22 | end
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/export/local_authority_links_exporter.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Export
3 | class LocalAuthorityLinksExporter < LocalLinksManager::Export::LinksExporter
4 | def links(local_authority_id, status)
5 | Link.enabled_links
6 | .where(local_authority_id:, status:)
7 | .joins(:local_authority, :service, :interaction)
8 | .select(*SELECTION)
9 | .order("services.lgsl_code", "interactions.lgil_code").all
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/export/service_links_exporter.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Export
3 | class ServiceLinksExporter < LocalLinksManager::Export::LinksExporter
4 | def links(service_id, status)
5 | Link.joins(:service).where(services: { id: service_id }, status:)
6 | .joins(:local_authority, :interaction)
7 | .select(*SELECTION)
8 | .order("local_authorities.name", "interactions.lgil_code").all
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/analytics_importer.rb:
--------------------------------------------------------------------------------
1 | require_relative "processor"
2 | require_relative "errors"
3 |
4 | module LocalLinksManager
5 | module Import
6 | class AnalyticsImporter
7 | def self.import
8 | new.import_records
9 | end
10 |
11 | def initialize(data = GoogleAnalytics::AnalyticsImportService.activity)
12 | @data = data
13 | @processed_ids = Set.new
14 | end
15 |
16 | def import_records
17 | @existing_ids_with_analytics = Set.new(Link.where.not(analytics: 0).pluck(:id))
18 | Processor.new(self).process
19 | end
20 |
21 | def each_item(&block)
22 | @data.each(&block)
23 | end
24 |
25 | def import_item(item, _response, summariser)
26 | link = Link.lookup_by_base_path(item[:base_path])
27 |
28 | if link
29 | link.update!(analytics: item[:clicks] || 0)
30 | summariser.increment_updated_record_count
31 | @processed_ids.add(link.id)
32 | else
33 | summariser.increment_missing_record_count
34 | end
35 | end
36 |
37 | def all_items_imported(response, _summariser)
38 | reset_count_on_links_not_in_analytics if response.successful?
39 | rescue StandardError => e
40 | response.errors << "Could not reset all old analytics counts due to: #{e}"
41 | end
42 |
43 | def import_name
44 | "Google Analytics Import"
45 | end
46 |
47 | def import_source_name
48 | "Downloaded Google Analytics stats"
49 | end
50 |
51 | private
52 |
53 | def reset_count_on_links_not_in_analytics
54 | links_to_reset = @existing_ids_with_analytics - @processed_ids
55 |
56 | links_to_reset.each do |id|
57 | Link.find(id).update!(analytics: 0)
58 | end
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/csv_downloader.rb:
--------------------------------------------------------------------------------
1 | require "csv"
2 |
3 | module LocalLinksManager
4 | module Import
5 | class CsvDownloader
6 | class Error < RuntimeError; end
7 |
8 | class DownloadError < Error; end
9 |
10 | class MalformedCSVError < Error; end
11 |
12 | def initialize(csv_url, header_conversions: {}, encoding: "UTF-8")
13 | @csv_url = csv_url
14 | @header_conversions = header_conversions
15 | @encoding = encoding
16 | end
17 |
18 | def each_row(&block)
19 | download do |csv|
20 | csv.each(&block)
21 | end
22 | end
23 |
24 | def download
25 | downloaded_csv do |data|
26 | yield CSV.parse(
27 | data,
28 | headers: true,
29 | header_converters: field_name_converter,
30 | )
31 | end
32 | rescue CSV::MalformedCSVError => e
33 | raise MalformedCSVError, "Error #{e.class} parsing CSV in #{self.class}"
34 | end
35 |
36 | private
37 |
38 | def downloaded_csv
39 | Tempfile.create(["local_links_manager_import", @csv_url.gsub(/[^0-9A-z.-]+/, "_"), "csv"]) do |temp_file|
40 | temp_file.set_encoding("ascii-8bit")
41 |
42 | response = Net::HTTP.get_response(URI.parse(@csv_url))
43 |
44 | unless response.code_type == Net::HTTPOK
45 | raise DownloadError, "Error downloading CSV in #{self.class}"
46 | end
47 |
48 | temp_file.write(response.body)
49 |
50 | temp_file.rewind
51 | temp_file.set_encoding(@encoding, "UTF-8")
52 | yield temp_file
53 | end
54 | end
55 |
56 | def field_name_converter
57 | lambda do |field|
58 | @header_conversions.key?(field) ? @header_conversions[field] : field
59 | end
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/error_message_formatter.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Import
3 | class ErrorMessageFormatter
4 | def initialize(klass, suffix, entries)
5 | @klass = klass
6 | @suffix = suffix
7 | @entries = entries
8 | end
9 |
10 | def message
11 | if @entries.count == 1
12 | "1 #{@klass} is #{@suffix}\n#{list_entries(@entries)}\n"
13 | else
14 | "#{@entries.count} #{@klass.pluralize} are #{@suffix}\n#{list_entries(@entries)}\n"
15 | end
16 | end
17 |
18 | private
19 |
20 | def list_entries(entries)
21 | entries.to_a.sort.join("\n")
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/errors.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Import
3 | module Errors
4 | class MissingIdentifierError < RuntimeError; end
5 |
6 | class MissingRecordError < RuntimeError; end
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/import_comparer.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Import
3 | class ImportComparer
4 | def initialize
5 | @records_in_source = Set.new
6 | @missing = Set.new
7 | end
8 |
9 | def add_source_record(record_key)
10 | @records_in_source.add(record_key)
11 | end
12 |
13 | def check_missing_records(saved_records)
14 | saved_records.each do |record|
15 | record_key = yield(record)
16 | unless @records_in_source.include? record_key
17 | @missing.add(record_key)
18 | end
19 | end
20 |
21 | @missing
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/missing_links.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Import
3 | class MissingLinks
4 | def self.add
5 | new.add_missing_links
6 | end
7 |
8 | def add_missing_links
9 | ServiceInteraction.where(live: true).find_each do |service_interaction|
10 | las_with_no_link(service_interaction).each do |local_authority_id|
11 | Rails.logger.info "Creating link for #{local_authority_id}, #{service_interaction.govuk_slug}"
12 | Link.create!(local_authority_id:, service_interaction:, analytics: 0, status: "missing", url: nil)
13 | end
14 | end
15 | end
16 |
17 | def las_with_no_link(service_interaction)
18 | service = service_interaction.service
19 |
20 | local_authorities_that_should_have_a_link = service.local_authorities.pluck(:id).sort
21 |
22 | local_authorities_with_a_link = Link.where(
23 | service_interaction:,
24 | local_authority_id: local_authorities_that_should_have_a_link,
25 | ).map(&:local_authority_id)
26 |
27 | local_authorities_that_should_have_a_link - local_authorities_with_a_link
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/processor.rb:
--------------------------------------------------------------------------------
1 | require_relative "response"
2 | require_relative "summariser"
3 |
4 | module LocalLinksManager
5 | module Import
6 | class Processor
7 | def initialize(importer)
8 | @importer = importer
9 | @response = Response.new
10 | @summariser = Summariser.new(@importer.import_name, @importer.import_source_name)
11 | end
12 |
13 | def process
14 | with_each_item do |item|
15 | summariser.counting_errors(response) do
16 | importer.import_item(item, response, summariser)
17 | end
18 | end
19 | Rails.logger.info summariser.summary
20 |
21 | response
22 | end
23 |
24 | private
25 |
26 | attr_reader :importer, :response, :summariser
27 |
28 | def with_each_item
29 | importer.each_item do |item|
30 | summariser.increment_import_source_count
31 | yield(item)
32 | end
33 | importer.all_items_imported(response, summariser) if importer.respond_to? :all_items_imported
34 | rescue StandardError => e
35 | error_message = "Error #{e.class} processing import in #{importer.class}: '#{e.message}'\n\n#{e.backtrace.join("\n")}"
36 | Rails.logger.error error_message
37 | response.errors << error_message
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/import/response.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | module Import
3 | class Response
4 | attr_accessor :errors
5 |
6 | def initialize
7 | @errors = []
8 | end
9 |
10 | def successful?
11 | @errors.empty?
12 | end
13 |
14 | def message
15 | successful? ? "Success" : @errors.join("\n")
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/app/lib/local_links_manager/link_resolver.rb:
--------------------------------------------------------------------------------
1 | module LocalLinksManager
2 | class LinkResolver
3 | def initialize(authority, service, interaction = nil)
4 | @authority = authority
5 | @service = service
6 | @interaction = interaction
7 | end
8 |
9 | def resolve
10 | if @interaction
11 | link_for_interaction
12 | else
13 | fallback_link
14 | end
15 | end
16 |
17 | private
18 |
19 | def link_for_interaction
20 | authority = @authority
21 | link = authority.links.lookup_by_service_and_interaction(@service, @interaction)
22 |
23 | while link.nil? && can_lookup_link_from_parent(authority)
24 | authority = authority.parent_local_authority
25 | link = authority.links.lookup_by_service_and_interaction(@service, @interaction)
26 | end
27 |
28 | link
29 | end
30 |
31 | def can_lookup_link_from_parent(authority)
32 | return false unless authority.parent_local_authority
33 |
34 | @service.local_authorities.exists?(authority.parent_local_authority.id)
35 | end
36 |
37 | def fallback_link
38 | if service_links_ordered_by_lgil.count == 1
39 | service_links_ordered_by_lgil.first
40 | else
41 | link_with_lowest_lgil_but_not_providing_information_lgil
42 | end
43 | end
44 |
45 | def service_links_ordered_by_lgil
46 | @service_links_ordered_by_lgil ||= @authority.links.for_service(@service).order("interactions.lgil_code").to_a
47 | end
48 |
49 | def link_with_lowest_lgil_but_not_providing_information_lgil
50 | service_links_ordered_by_lgil.detect do |link|
51 | link.interaction.lgil_code != Interaction::PROVIDING_INFORMATION_LGIL
52 | end
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/app/mailers/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/app/mailers/.keep
--------------------------------------------------------------------------------
/app/models/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/app/models/.keep
--------------------------------------------------------------------------------
/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/app/models/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/app/models/concerns/.keep
--------------------------------------------------------------------------------
/app/models/interaction.rb:
--------------------------------------------------------------------------------
1 | class Interaction < ApplicationRecord
2 | PROVIDING_INFORMATION_LGIL = 8
3 |
4 | validates :lgil_code, :label, :slug, presence: true, uniqueness: true
5 |
6 | has_many :service_interactions, dependent: :destroy
7 | has_many :services, through: :service_interactions
8 |
9 | def to_param
10 | slug
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/models/service.rb:
--------------------------------------------------------------------------------
1 | class Service < ApplicationRecord
2 | validates :lgsl_code, :label, :slug, presence: true, uniqueness: true
3 |
4 | has_many :service_interactions, dependent: :destroy
5 | has_many :links, through: :service_interactions
6 | has_many :interactions, through: :service_interactions
7 | has_many :service_tiers, dependent: :destroy
8 | has_many :local_authorities, through: :service_tiers
9 |
10 | scope :enabled, -> { where(enabled: true) }
11 |
12 | VALID_TIERS = ["district/unitary", "county/unitary", "all"].freeze
13 |
14 | def tiers
15 | service_tiers.pluck(:tier_id).map { |t_id| Tier.as_string(t_id) }
16 | end
17 |
18 | def update_broken_link_count
19 | update(broken_link_count: Link.for_service(self).broken_or_missing.count)
20 | end
21 |
22 | def valid_tier?(tier)
23 | VALID_TIERS.include?(tier)
24 | end
25 |
26 | def delete_and_create_tiers(tier_name)
27 | delete_all_tiers
28 | ServiceTier.create_tiers(required_tiers(tier_name), self)
29 | end
30 |
31 | def delete_all_tiers
32 | service_tiers.destroy_all
33 | end
34 |
35 | def required_tiers(tier_name)
36 | case tier_name
37 | when "district/unitary"
38 | [Tier.district, Tier.unitary]
39 | when "county/unitary"
40 | [Tier.county, Tier.unitary]
41 | when "all"
42 | [Tier.district, Tier.unitary, Tier.county]
43 | end
44 | end
45 |
46 | def to_param
47 | slug
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/app/models/service_interaction.rb:
--------------------------------------------------------------------------------
1 | class ServiceInteraction < ApplicationRecord
2 | validates :service_id, :interaction_id, presence: true
3 | validates :service_id, uniqueness: { scope: :interaction_id }
4 |
5 | belongs_to :service, touch: true
6 | belongs_to :interaction
7 | has_many :links, dependent: :destroy
8 |
9 | delegate :lgsl_code, to: :service
10 | delegate :lgil_code, to: :interaction
11 |
12 | def self.lookup_by_lgsl_and_lgil(lgsl_code, lgil_code)
13 | includes(:service, :interaction)
14 | .references(:service, :interaction)
15 | .find_by(services: { lgsl_code: }, interactions: { lgil_code: })
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/models/service_tier.rb:
--------------------------------------------------------------------------------
1 | class ServiceTier < ApplicationRecord
2 | belongs_to :service
3 | belongs_to :local_authority, foreign_key: :tier_id, primary_key: :tier_id, optional: true
4 | validates :service_id, uniqueness: { scope: :tier_id }
5 |
6 | def self.create_tiers(tiers, service)
7 | tiers.each { |tier| ServiceTier.create(service:, tier_id: tier) }
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/models/tier.rb:
--------------------------------------------------------------------------------
1 | class Tier
2 | COUNTY = 1
3 | DISTRICT = 2
4 | UNITARY = 3
5 |
6 | def self.county
7 | COUNTY
8 | end
9 |
10 | def self.district
11 | DISTRICT
12 | end
13 |
14 | def self.unitary
15 | UNITARY
16 | end
17 |
18 | def self.as_string(tier)
19 | case tier
20 | when COUNTY
21 | "county"
22 | when DISTRICT
23 | "district"
24 | when UNITARY
25 | "unitary"
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User < ApplicationRecord
2 | include GDS::SSO::User
3 | serialize :permissions, type: Array, coder: YAML
4 | end
5 |
--------------------------------------------------------------------------------
/app/presenters/interaction_presenter.rb:
--------------------------------------------------------------------------------
1 | class InteractionPresenter < SimpleDelegator
2 | def initialize(interaction, presented_link = nil)
3 | @link = presented_link
4 | super(interaction)
5 | end
6 |
7 | def link_url
8 | @link.url if @link
9 | end
10 |
11 | def link_status
12 | @link ? @link.status_description : "No link"
13 | end
14 |
15 | def link_last_checked
16 | @link ? @link.last_checked : ""
17 | end
18 |
19 | def button_text
20 | @link ? "Edit link" : "Add link"
21 | end
22 |
23 | def label_status_class
24 | @link ? @link.label_status_class : ""
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/app/presenters/link_api_response_presenter.rb:
--------------------------------------------------------------------------------
1 | class LinkApiResponsePresenter
2 | def initialize(given_authority, link)
3 | @given_authority = given_authority
4 | @link = link
5 | end
6 |
7 | def present
8 | local_authority_details.merge(link_details)
9 | end
10 |
11 | private
12 |
13 | attr_reader :given_authority, :link
14 |
15 | def authority
16 | link&.local_authority || given_authority
17 | end
18 |
19 | def local_authority_details
20 | { "local_authority" => LocalAuthorityHashPresenter.new(authority).to_h }
21 | end
22 |
23 | def link_details
24 | return {} unless link
25 |
26 | {
27 | "local_interaction" => {
28 | "lgsl_code" => link.service.lgsl_code,
29 | "lgil_code" => link.interaction.lgil_code,
30 | "status" => link.status,
31 | "title" => link.title,
32 | "url" => link.url,
33 | },
34 | }
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/app/presenters/link_presenter.rb:
--------------------------------------------------------------------------------
1 | class LinkPresenter < SimpleDelegator
2 | include UrlStatusPresentation
3 | end
4 |
--------------------------------------------------------------------------------
/app/presenters/links_table_presenter.rb:
--------------------------------------------------------------------------------
1 | class LinksTablePresenter
2 | def initialize(links, view_context, remove_council: false, remove_service: false)
3 | @links = links
4 | @view_context = view_context
5 | @remove_council = remove_council
6 | @remove_service = remove_service
7 | end
8 |
9 | def rows
10 | table_rows = @links.map do |link|
11 | si = link.service_interaction
12 | title = si.govuk_title || link.service.label
13 | pres = LinkPresenter.new(link)
14 | [
15 | { text: link.analytics.to_i, format: "numeric" },
16 | { text: "#{title}".html_safe },
17 | { text: "#{link.interaction.label}".html_safe },
18 | { text: link.local_authority.name },
19 | { text: "#{pres.status_description}".html_safe },
20 | { text: @view_context.link_to("Edit #{link.local_authority.name} - #{si.service.label} - #{si.interaction.label}".html_safe, @view_context.edit_link_path(link.local_authority, link.service, link.interaction), class: "govuk-link") },
21 | ]
22 | end
23 |
24 | table_rows.each { |tr| tr.delete_at(3) } if @remove_council
25 | table_rows.each { |tr| tr.delete_at(1) } if @remove_service
26 |
27 | table_rows
28 | end
29 |
30 | def headers
31 | table_headers = [
32 | {
33 | text: "Visits this week",
34 | format: "numeric",
35 | },
36 | {
37 | text: "Service",
38 | },
39 | {
40 | text: "Interaction",
41 | },
42 | {
43 | text: "Council",
44 | },
45 | {
46 | text: "Status",
47 | },
48 | {
49 | text: "Edit".html_safe,
50 | },
51 | ]
52 |
53 | table_headers.delete_at(3) if @remove_council
54 | table_headers.delete_at(1) if @remove_service
55 |
56 | table_headers
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/app/presenters/local_authorities_table_presenter.rb:
--------------------------------------------------------------------------------
1 | class LocalAuthoritiesTablePresenter
2 | def initialize(local_authorities, view_context)
3 | @local_authorities = local_authorities
4 | @view_context = view_context
5 | end
6 |
7 | def rows
8 | @local_authorities.map do |authority|
9 | la_presenter = LocalAuthorityPresenter.new(authority)
10 |
11 | [
12 | { text: authority.links.sum { |l| l.analytics.to_i }, format: "numeric" },
13 | { text: authority.name },
14 | { text: "#{la_presenter.homepage_status}".html_safe },
15 | { text: authority.active? ? "Yes" : "No" },
16 | { text: authority.broken_link_count, format: "numeric" },
17 | { text: @view_context.link_to("Edit #{authority.name}".html_safe, @view_context.local_authority_path(authority.slug, filter: "broken_links"), class: "govuk-link") },
18 | ]
19 | end
20 | end
21 |
22 | def headers
23 | [
24 | {
25 | text: "Visits this week",
26 | format: "numeric",
27 | },
28 | {
29 | text: "Council Name",
30 | },
31 | {
32 | text: "Homepage Status",
33 | },
34 | {
35 | text: "Active?",
36 | },
37 | {
38 | text: "Broken Links",
39 | format: "numeric",
40 | },
41 | {
42 | text: "Edit".html_safe,
43 | },
44 | ]
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/app/presenters/local_authority_api_response_presenter.rb:
--------------------------------------------------------------------------------
1 | class LocalAuthorityApiResponsePresenter
2 | def initialize(authority)
3 | @authority = authority
4 | end
5 |
6 | def present
7 | local_authority_json = {
8 | "local_authorities" => [
9 | present_local_authority(@authority),
10 | ],
11 | }
12 | if parent
13 | local_authority_json["local_authorities"] << present_local_authority(parent)
14 | end
15 |
16 | local_authority_json
17 | end
18 |
19 | private
20 |
21 | def present_local_authority(local_authority)
22 | LocalAuthorityHashPresenter.new(local_authority).to_h
23 | end
24 |
25 | def parent
26 | @parent ||= @authority.parent_local_authority
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/app/presenters/local_authority_external_content_presenter.rb:
--------------------------------------------------------------------------------
1 | class LocalAuthorityExternalContentPresenter
2 | def initialize(local_authority)
3 | @local_authority = local_authority
4 | end
5 |
6 | def present_for_publishing_api
7 | {
8 | description: "Website of #{@local_authority.name}",
9 | details: {
10 | url: @local_authority.homepage_url,
11 | },
12 | document_type: "external_content",
13 | publishing_app: "local-links-manager",
14 | schema_name: "external_content",
15 | title: @local_authority.name,
16 | update_type: "minor",
17 | }
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/app/presenters/local_authority_hash_presenter.rb:
--------------------------------------------------------------------------------
1 | class LocalAuthorityHashPresenter
2 | def initialize(authority)
3 | @authority = authority
4 | end
5 |
6 | def to_h
7 | hash = {
8 | "name" => @authority.name,
9 | "homepage_url" => @authority.homepage_url,
10 | "country_name" => @authority.country_name,
11 | "tier" => @authority.tier,
12 | "slug" => @authority.slug,
13 | "gss" => @authority.gss,
14 | }
15 | hash["snac"] = @authority.snac if @authority.snac
16 | hash
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/presenters/service_link_presenter.rb:
--------------------------------------------------------------------------------
1 | class ServiceLinkPresenter < SimpleDelegator
2 | include UrlStatusPresentation
3 | attr_reader :view_context, :first
4 |
5 | def initialize(link, view_context:, first:)
6 | super(link)
7 | @view_context = view_context
8 | @first = first
9 | end
10 |
11 | delegate :label, to: :interaction, prefix: true
12 |
13 | delegate :lgsl_code, to: :service
14 |
15 | delegate :label, to: :service, prefix: true
16 |
17 | delegate :slug, to: :service, prefix: true
18 |
19 | delegate :govuk_title, to: :service_interaction
20 |
21 | def row_data
22 | {
23 | local_authority_id: local_authority.id,
24 | service_id: service.id,
25 | interaction_id: interaction.id,
26 | url:,
27 | }
28 | end
29 |
30 | def edit_path
31 | view_context.edit_link_path(
32 | local_authority,
33 | service,
34 | interaction,
35 | )
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/app/presenters/service_presenter.rb:
--------------------------------------------------------------------------------
1 | class ServicePresenter < SimpleDelegator
2 | def summary_list(view_context)
3 | govuk_links = ServiceInteraction.where(service_id: id).map do |si|
4 | si.govuk_title ? view_context.link_to(si.govuk_title, "#{Plek.website_root}/#{si.govuk_slug}", class: "govuk-link") : nil
5 | end
6 |
7 | summary_items = [
8 | { field: "Local Government Service List (LGSL) Code", value: lgsl_code },
9 | { field: "Owning Departments", value: organisation_slugs.join(", ") },
10 | { field: "Page title(s) on GOV.UK", value: govuk_links.compact.any? ? govuk_links.compact.join("").html_safe : "Not used on GOV.UK" },
11 | ]
12 |
13 | { items: summary_items }
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/presenters/services_table_presenter.rb:
--------------------------------------------------------------------------------
1 | class ServicesTablePresenter
2 | def initialize(services, view_context)
3 | @services = services
4 | @view_context = view_context
5 | end
6 |
7 | def rows
8 | @services.map do |service|
9 | govuk_pages = ServiceInteraction.where(service:).map(&:govuk_title)
10 |
11 | [
12 | { text: service.links.sum { |l| l.analytics.to_i }, format: "numeric" },
13 | { text: service.label },
14 | { text: govuk_pages.compact.any? ? govuk_pages.compact.join("
").html_safe : "Not used on GOV.UK" },
15 | { text: service.lgsl_code },
16 | { text: service.broken_link_count, format: "numeric" },
17 | { text: @view_context.link_to("Edit #{service.label}".html_safe, @view_context.service_path(service, filter: "broken_links"), class: "govuk-link") },
18 | ]
19 | end
20 | end
21 |
22 | def headers
23 | [
24 | {
25 | text: "Visits this week",
26 | format: "numeric",
27 | },
28 | {
29 | text: "Service Name",
30 | },
31 | {
32 | text: "Page title(s) on GOV.UK",
33 | },
34 | {
35 | text: "LGSL Code",
36 | },
37 | {
38 | text: "Broken Links",
39 | format: "numeric",
40 | },
41 | {
42 | text: "Edit".html_safe,
43 | },
44 | ]
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/app/presenters/url_status_presentation.rb:
--------------------------------------------------------------------------------
1 | module UrlStatusPresentation
2 | include ActionView::Helpers::DateHelper
3 |
4 | def status_description
5 | return "Not checked" unless status
6 | return "Good" if status == "ok"
7 |
8 | case status
9 | when "caution"
10 | "Note: #{problem_summary}"
11 | when "broken"
12 | "Broken: #{problem_summary}"
13 | when "missing"
14 | "Missing"
15 | when "pending"
16 | "Pending"
17 | else
18 | problem_summary
19 | end
20 | end
21 |
22 | def status_detailed_description
23 | (link_errors + link_warnings).uniq
24 | end
25 |
26 | def label_status_class
27 | return nil unless status
28 | return "label label-success" if status == "ok"
29 | return "label label-danger" if status == "broken"
30 | return "label label-warning" if status == "caution"
31 | return "label label-danger" if status == "missing"
32 |
33 | "label label-info"
34 | end
35 |
36 | def status_tag_colour
37 | return "grey" unless status
38 | return "green" if status == "ok"
39 | return "yellow" if status == "caution"
40 |
41 | "red"
42 | end
43 |
44 | def last_checked
45 | if link_last_checked
46 | "#{time_ago_in_words(link_last_checked)} ago"
47 | else
48 | "Link not checked"
49 | end
50 | end
51 |
52 | def updated?
53 | view_context.flash[:updated].present? &&
54 | view_context.flash[:updated]["url"] == url &&
55 | view_context.flash[:updated]["lgil"] == interaction.lgil_code
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/app/services/local_authority_external_content_publisher.rb:
--------------------------------------------------------------------------------
1 | class LocalAuthorityExternalContentPublisher
2 | attr_reader :local_authority, :publishing_api
3 |
4 | def initialize(local_authority)
5 | @local_authority = local_authority
6 | @publishing_api = GdsApi.publishing_api
7 | end
8 |
9 | def publish
10 | payload = LocalAuthorityExternalContentPresenter.new(local_authority)
11 | .present_for_publishing_api
12 |
13 | publishing_api.put_content(content_id, payload)
14 | publishing_api.publish(content_id)
15 | end
16 |
17 | def unpublish
18 | return unless published?
19 |
20 | publishing_api.unpublish(content_id, type: "gone")
21 | end
22 |
23 | private
24 |
25 | def content_id
26 | local_authority.content_id
27 | end
28 |
29 | def published?
30 | content = publishing_api.get_live_content(content_id)
31 | content.to_hash["publication_state"] == "published"
32 | rescue GdsApi::HTTPNotFound
33 | # Not present, so definitely not published
34 | false
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/app/validators/non_blank_url_validator.rb:
--------------------------------------------------------------------------------
1 | class NonBlankUrlValidator < ActiveModel::EachValidator
2 | def validate_each(record, attribute, value)
3 | return if value.blank?
4 |
5 | valid_url = begin
6 | uri = Addressable::URI.parse(value)
7 | uri.scheme.present? && uri.host.present? && uri.host.include?(".")
8 | rescue Addressable::URI::InvalidURIError
9 | false
10 | end
11 |
12 | record.errors.add attribute, (options[:message] || "(#{value}) is not a URL") unless valid_url
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 | <% environment = GovukPublishingComponents::AppHelpers::Environment.current_acceptance_environment %>
2 |
3 | <% content_for :head do %>
4 | <%= javascript_include_tag "es6-components", type: "module" %>
5 | <% end %>
6 |
7 | <% content_for :body do %>
8 |
<%= pres.status_description %>: <%= message %>
8 | <% end %> 9 |"> <%= pres.status_description %>: <%= last_message %>
10 | 11 | <% if link.suggested_fix? %> 12 |Suggested fix: <%= link.suggested_fix %>
13 | <% end %> 14 |Produces a CSV which can be altered and uploaded to update your links as a batch.
9 | <%= form_tag(form_path) do %> 10 | 11 | <%= render "govuk_publishing_components/components/checkboxes", { 12 | name: "links_status_checkbox[]", 13 | heading: "Include links of type:", 14 | hint_text: "", 15 | items: [ 16 | { label: "Ok", value: "ok", checked: true }, 17 | { label: "Broken", value: "broken", checked: true }, 18 | { label: "Caution", value: "caution", checked: true }, 19 | { label: "Missing", value: "missing", checked: true }, 20 | { label: "Pending", value: "pending", checked: true }, 21 | ], 22 | } %> 23 | 24 | <%= render "govuk_publishing_components/components/button", { text: "Download Links" } %> 25 | 26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/views/shared/_flash.html.erb: -------------------------------------------------------------------------------- 1 | <% if flash[:danger].present? %> 2 | <%= render "govuk_publishing_components/components/error_alert", { message: sanitize(flash[:danger]) } %> 3 | <% end %> 4 | 5 | <% if flash[:info].present? %> 6 | <%= render "govuk_publishing_components/components/notice", { description: sanitize(flash[:info]) } %> 7 | <% end %> 8 | 9 | <% if flash[:success].present? %> 10 | <%= render "govuk_publishing_components/components/success_alert", { message: sanitize(flash[:success]) } %> 11 | <% end %> 12 | -------------------------------------------------------------------------------- /app/views/shared/_links_table.html.erb: -------------------------------------------------------------------------------- 1 | <%= render "govuk_publishing_components/components/table", { 2 | filterable: true, 3 | label: "Filter Links", 4 | head: links_table_presenter.headers, 5 | rows: links_table_presenter.rows, 6 | } %> 7 | -------------------------------------------------------------------------------- /app/views/shared/_upload_links_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= render "govuk_publishing_components/components/heading", { 2 | text: title, 3 | heading_level: 1, 4 | font_size: "l", 5 | margin_bottom: 5, 6 | } %> 7 | 8 | <%= form_tag(form_path, multipart: true) do %> 9 | <%= render "govuk_publishing_components/components/file_upload", { 10 | label: { text: "Upload a file" }, 11 | hint: "Accepts a CSV in the same format provided by the download option. Will create or update any link which 12 | has a value in the New URL or New Title columns.", 13 | name: "csv", 14 | accept: "text/csv", 15 | } %> 16 | <%= render "govuk_publishing_components/components/button", { text: "Upload Links" } %> 17 | <% end %> 18 | -------------------------------------------------------------------------------- /bin/brakeman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | ARGV.unshift("--ensure-latest") 6 | 7 | load Gem.bin_path("brakeman", "brakeman") 8 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /bin/thrust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | load Gem.bin_path("thruster", "thrust") 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module LocalLinksManager 22 | class Application < Rails::Application 23 | # Initialize configuration defaults for originally generated Rails version. 24 | config.load_defaults 8.0 25 | 26 | # Please, add to the `ignore` list any other `lib` subdirectories that do 27 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 28 | # Common ones are `templates`, `generators`, or `middleware`, for example. 29 | config.autoload_lib(ignore: %w[assets tasks]) 30 | 31 | # Configuration for the application, engines, and railties goes here. 32 | # 33 | # These settings can be overridden in specific environments using the files 34 | # in config/environments, which are processed later. 35 | # 36 | # config.time_zone = "Central Time (US & Canada)" 37 | # config.eager_load_paths << Rails.root.join("extras") 38 | 39 | # Set asset path to be application specific so that we can put all GOV.UK 40 | # assets into an S3 bucket and distinguish app by path. 41 | config.assets.prefix = "/assets/local-links-manager" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/breadcrumbs.rb: -------------------------------------------------------------------------------- 1 | crumb :root do 2 | link "Local links", root_path 3 | end 4 | 5 | crumb :local_authorities do 6 | link "Local authorities", local_authorities_path 7 | end 8 | 9 | crumb :local_authority do |local_authority| 10 | link local_authority.name, local_authority_path(local_authority.slug) 11 | parent :local_authorities 12 | end 13 | 14 | crumb :services do 15 | link "Services", services_path 16 | end 17 | 18 | crumb :service do |service| 19 | link service.label, service_path(service) 20 | parent :services 21 | end 22 | 23 | crumb :links do |local_authority, service, interaction| 24 | link interaction.label, link_path(local_authority.slug, service.slug, interaction.slug) 25 | parent :service, service 26 | end 27 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | template: template0 5 | 6 | development: 7 | <<: *default 8 | database: local-links-manager_development 9 | url: <%= ENV['DATABASE_URL'] %> 10 | 11 | # Warning: The database defined as "test" will be erased and 12 | # re-generated from your development database when you run "rake". 13 | # Do not set this db to the same as development or production. 14 | 15 | test: &test 16 | <<: *default 17 | database: local-links-manager_test<%= ENV['TEST_ENV_NUMBER'] %> 18 | url: <%= ENV['TEST_DATABASE_URL'] %> 19 | 20 | cucumber: 21 | <<: *test 22 | 23 | production: 24 | <<: *default 25 | url: <%= ENV['DATABASE_URL'] %> 26 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += %i[ 7 | passw email secret token _key crypt salt certificate otp ssn cvv cvc 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/govuk_publishing_components.rb: -------------------------------------------------------------------------------- 1 | GovukPublishingComponents.configure do |c| 2 | c.exclude_css_from_static = false 3 | end 4 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /config/initializers/prometheus.rb: -------------------------------------------------------------------------------- 1 | require "govuk_app_config/govuk_prometheus_exporter" 2 | GovukPrometheusExporter.configure 3 | -------------------------------------------------------------------------------- /config/initializers/secrets_to_credentials.rb: -------------------------------------------------------------------------------- 1 | # Rails 7 has begung to deprecate Rails.application.secrets in favour 2 | # of Rails.application.credentials, but that adds the burden of master key 3 | # adminstration without giving us any benefit (because our production 4 | # secrets are handled as env vars, not committed to our repo. Here we 5 | # loads the config/secrets.YML values into Rails.application.credentials, 6 | # retaining the existing behaviour while dropping deprecated references. 7 | 8 | Rails.application.credentials.merge!(Rails.application.config_for(:secrets)) 9 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: "_local_links_manager_session" 4 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | require "govuk_app_config/govuk_puma" 2 | GovukPuma.configure_rails(self) 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root to: "links#index" 3 | 4 | mount GovukPublishingComponents::Engine, at: "/component-guide" 5 | 6 | get "/healthcheck/live", to: proc { [200, {}, %w[OK]] } 7 | get "/healthcheck/ready", to: GovukHealthcheck.rack_response( 8 | GovukHealthcheck::ActiveRecord, 9 | GovukHealthcheck::Redis, 10 | ) 11 | 12 | resources "local_authorities", only: %i[index show update], param: :local_authority_slug do 13 | member do 14 | get "download_links_form" 15 | post "download_links_csv" 16 | get "upload_links_form" 17 | post "upload_links_csv" 18 | get "edit_url" 19 | end 20 | end 21 | 22 | resources "services", only: %i[index show], param: :service_slug do 23 | member do 24 | get "download_links_form" 25 | post "download_links_csv" 26 | get "upload_links_form" 27 | post "upload_links_csv" 28 | get "update-owner-form" 29 | patch "update-owner" 30 | end 31 | end 32 | 33 | get "/local_authorities/:local_authority_slug/services/:service_slug", to: redirect("/local_authorities/%{local_authority_slug}") 34 | 35 | scope "/local_authorities/:local_authority_slug/services/:service_slug" do 36 | resource ":interaction_slug", only: %i[edit update destroy], controller: "links", as: "link" 37 | end 38 | 39 | get "/check_homepage_links_status.csv", to: "links#homepage_links_status_csv" 40 | get "/check_links_status.csv", to: "links#links_status_csv" 41 | 42 | get "/bad_links_url_status.csv", to: "links#bad_links_url_and_status_csv" 43 | 44 | get "/bad_homepage_url_status.csv", to: "local_authorities#bad_homepage_url_and_status_csv" 45 | 46 | get "/api/link", to: "api#link" 47 | 48 | get "/api/local-authority", to: "api#local_authority" 49 | 50 | post "/link-check-callback", to: "webhooks#link_check_callback", as: :link_checker_webhook 51 | end 52 | -------------------------------------------------------------------------------- /config/schedule.rb: -------------------------------------------------------------------------------- 1 | # File required to ensure cronjobs are removed 2 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 139e0d078b31c72aeb24893f994138f76fc3dc8f60d6964cc5cca0383b0cd54872eb3ad15f36d96232bc02b8ccc247d9ff0e63f969d3dc147b233591ce4e8c01 15 | link_checker_api_secret_token: h830foainflkwn439qfhla 16 | 17 | test: 18 | secret_key_base: d3e0f487c481073197624c84467c2278040043c05a721cdb344e627e37889c87979165ed276c87fa5ed2336dbc164bfb155f5995aad04dd4434ec304fda911a8 19 | link_checker_api_secret_token: fshur3h89fhu4jh3iouf90 20 | 21 | # Do not keep production secrets in the repository, 22 | # instead read values from the environment. 23 | production: 24 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 25 | link_checker_api_secret_token: <%= ENV["LINK_CHECKER_API_SECRET_TOKEN"] %> 26 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt", 6 | ) 7 | -------------------------------------------------------------------------------- /config/unicorn.rb: -------------------------------------------------------------------------------- 1 | require "govuk_app_config/govuk_unicorn" 2 | GovukUnicorn.configure(self) 3 | 4 | working_directory File.dirname(File.dirname(__FILE__)) 5 | 6 | # Preload the entire app 7 | preload_app true 8 | 9 | before_fork do |_server, _worker| 10 | # The following is highly recomended for Rails + "preload_app true" 11 | # as there's no need for the master process to hold a connection. 12 | defined?(ActiveRecord::Base) && 13 | ActiveRecord::Base.connection.disconnect! 14 | end 15 | 16 | after_fork do |_server, _worker| 17 | defined?(ActiveRecord::Base) && 18 | ActiveRecord::Base.establish_connection 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20160413110438_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | t.string :email 6 | t.string :uid 7 | t.string :organisation_slug 8 | t.string :organisation_content_id 9 | t.text :permissions 10 | t.boolean :remotely_signed_out, default: false 11 | t.boolean :disabled, default: false 12 | 13 | t.timestamps null: false 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20160415102439_create_local_authorities.rb: -------------------------------------------------------------------------------- 1 | class CreateLocalAuthorities < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :local_authorities do |t| 4 | t.string :gss 5 | t.string :homepage_url 6 | t.string :name 7 | t.string :slug 8 | t.string :snac 9 | t.string :tier 10 | 11 | t.timestamps null: false 12 | end 13 | 14 | add_index :local_authorities, :gss, unique: true 15 | add_index :local_authorities, :snac, unique: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20160427100154_create_interactions.rb: -------------------------------------------------------------------------------- 1 | class CreateInteractions < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :interactions do |t| 4 | t.integer :lgil_code 5 | t.string :label 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20160427110426_create_services.rb: -------------------------------------------------------------------------------- 1 | class CreateServices < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :services do |t| 4 | t.integer :lgsl_code 5 | t.string :label 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20160428164235_add_indexes_to_interaction.rb: -------------------------------------------------------------------------------- 1 | class AddIndexesToInteraction < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :interactions, :lgil_code, unique: true 4 | add_index :interactions, :label, unique: true 5 | change_column_null :interactions, :lgil_code, false 6 | change_column_null :interactions, :label, false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20160503101228_add_indexes_to_services.rb: -------------------------------------------------------------------------------- 1 | class AddIndexesToServices < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :services, :lgsl_code, unique: true 4 | add_index :services, :label, unique: true 5 | change_column_null :services, :lgsl_code, false 6 | change_column_null :services, :label, false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20160504113456_create_service_interactions.rb: -------------------------------------------------------------------------------- 1 | class CreateServiceInteractions < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :service_interactions do |t| 4 | t.integer :service_id 5 | t.integer :interaction_id 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20160504133456_add_indexes_to_service_interactions.rb: -------------------------------------------------------------------------------- 1 | class AddIndexesToServiceInteractions < ActiveRecord::Migration[5.0] 2 | def change 3 | add_foreign_key :service_interactions, :services 4 | add_foreign_key :service_interactions, :interactions 5 | 6 | add_index :service_interactions, %i[service_id interaction_id], unique: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20160511144329_add_tier_to_services.rb: -------------------------------------------------------------------------------- 1 | class AddTierToServices < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :services, :tier, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160513113110_add_slug_to_services.rb: -------------------------------------------------------------------------------- 1 | class AddSlugToServices < ActiveRecord::Migration[5.0] 2 | def up 3 | add_column :services, :slug, :string, unique: true 4 | 5 | Service.all.each do |service| 6 | service.slug = service.label.parameterize 7 | service.save! 8 | end 9 | 10 | change_column_null :services, :slug, false 11 | end 12 | 13 | def down 14 | remove_column :services, :slug 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20160517134617_create_links.rb: -------------------------------------------------------------------------------- 1 | class CreateLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :links do |t| 4 | t.references :local_authority, index: true, foreign_key: true, null: false 5 | t.references :service_interaction, index: true, foreign_key: true, null: false 6 | t.string :url, null: false 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :links, %i[local_authority_id service_interaction_id], unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20160523155343_add_enabled_column_to_service.rb: -------------------------------------------------------------------------------- 1 | class AddEnabledColumnToService < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :services, :enabled, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160525152805_add_slug_to_interactions.rb: -------------------------------------------------------------------------------- 1 | class AddSlugToInteractions < ActiveRecord::Migration[5.0] 2 | def up 3 | add_column :interactions, :slug, :string, unique: true 4 | 5 | Interaction.all.each do |interaction| 6 | interaction.slug = interaction.label.parameterize 7 | interaction.save! 8 | end 9 | 10 | change_column_null :interactions, :slug, false 11 | end 12 | 13 | def down 14 | remove_column :interactions, :slug 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20160615155529_add_index_to_local_authorities.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToLocalAuthorities < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :local_authorities, :slug, unique: true 4 | 5 | change_column_null :local_authorities, :gss, false 6 | change_column_null :local_authorities, :name, false 7 | change_column_null :local_authorities, :snac, false 8 | change_column_null :local_authorities, :slug, false 9 | change_column_null :local_authorities, :tier, false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20160620155419_add_status_and_link_last_checked_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddStatusAndLinkLastCheckedToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :links, :status, :string 4 | add_column :links, :link_last_checked, :datetime 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20160621090108_add_status_and_link_last_checked_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddStatusAndLinkLastCheckedToLocalAuthority < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :local_authorities, :status, :string 4 | add_column :local_authorities, :link_last_checked, :datetime 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20160729113356_add_parent_local_authority_id_to_local_authorities.rb: -------------------------------------------------------------------------------- 1 | class AddParentLocalAuthorityIdToLocalAuthorities < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :local_authorities, :parent_local_authority_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20161014123008_strip_urls.rb: -------------------------------------------------------------------------------- 1 | class StripUrls < ActiveRecord::Migration[5.0] 2 | def change 3 | Link.all.each do |link| 4 | link.url.strip! 5 | link.save! if link.url_changed? 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20161104150651_add_broken_link_count_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddBrokenLinkCountToLocalAuthority < ActiveRecord::Migration[5.0] 2 | def up 3 | add_column :local_authorities, :broken_link_count, :integer, default: 0 4 | 5 | LocalAuthority.all.map(&:update_broken_link_count) 6 | end 7 | 8 | def down 9 | remove_column :local_authorities, :broken_link_count 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20161110122049_add_broken_link_count_to_service.rb: -------------------------------------------------------------------------------- 1 | class AddBrokenLinkCountToService < ActiveRecord::Migration[5.0] 2 | def up 3 | add_column :services, :broken_link_count, :integer, default: 0 4 | 5 | Service.all.map(&:update_broken_link_count) 6 | end 7 | 8 | def down 9 | remove_column :services, :broken_link_count 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20161114122001_create_tier_and_service_tier.rb: -------------------------------------------------------------------------------- 1 | class CreateTierAndServiceTier < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :service_tiers do |t| 4 | t.integer :tier_id, null: false, index: true 5 | end 6 | 7 | add_reference :service_tiers, :service, null: false, index: true, foreign_key: true 8 | 9 | add_column :local_authorities, :tier_id, :integer, index: true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20161116153011_delete_old_tier_columns.rb: -------------------------------------------------------------------------------- 1 | class DeleteOldTierColumns < ActiveRecord::Migration[5.0] 2 | def change 3 | remove_column :local_authorities, :tier 4 | remove_column :services, :tier 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20161212140755_add_created_at_to_service_tiers.rb: -------------------------------------------------------------------------------- 1 | class AddCreatedAtToServiceTiers < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :service_tiers, :created_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170223163013_add_url_index_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddUrlIndexToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :links, :url 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170405153230_add_linkerrors_and_linkwarnings_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddLinkerrorsAndLinkwarningsToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :links, :link_errors, :json 4 | add_column :links, :link_warnings, :json 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20170406133715_add_link_errors_and_link_warnings_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddLinkErrorsAndLinkWarningsToLocalAuthority < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :local_authorities, :link_errors, :json 4 | add_column :local_authorities, :link_warnings, :json 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20170410075304_add_govuk_slug_and_title_to_service_interactions.rb: -------------------------------------------------------------------------------- 1 | class AddGovukSlugAndTitleToServiceInteractions < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :service_interactions, :govuk_slug, :string, unique: true 4 | add_column :service_interactions, :govuk_title, :string 5 | add_column :service_interactions, :live, :boolean 6 | 7 | add_index :service_interactions, :govuk_slug 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20170411084006_add_default_to_link_errors_and_link_warnings.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultToLinkErrorsAndLinkWarnings < ActiveRecord::Migration[5.0] 2 | def change 3 | change_column_default :links, :link_errors, {} 4 | change_column_default :local_authorities, :link_errors, {} 5 | change_column_default :links, :link_warnings, {} 6 | change_column_default :local_authorities, :link_warnings, {} 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20170412223705_add_analytics_index_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddAnalyticsIndexToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :links, :analytics, :integer 4 | 5 | add_index :links, :analytics 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20170703154357_change_link_errors_and_warnings_to_arrays.rb: -------------------------------------------------------------------------------- 1 | class ChangeLinkErrorsAndWarningsToArrays < ActiveRecord::Migration[5.0] 2 | def change 3 | change_column_default :links, :link_warnings, nil 4 | change_column_default :links, :link_errors, nil 5 | change_column_default :local_authorities, :link_warnings, nil 6 | change_column_default :local_authorities, :link_errors, nil 7 | 8 | change_column :links, :link_warnings, "character varying[] USING array[]::character varying[]" 9 | change_column :links, :link_errors, "character varying[] USING array[]::character varying[]" 10 | change_column :local_authorities, :link_warnings, "character varying[] USING array[]::character varying[]" 11 | change_column :local_authorities, :link_errors, "character varying[] USING array[]::character varying[]" 12 | 13 | change_column_default :links, :link_warnings, [] 14 | change_column_default :links, :link_errors, [] 15 | change_column_default :local_authorities, :link_warnings, [] 16 | change_column_default :local_authorities, :link_errors, [] 17 | 18 | change_column_null :links, :link_warnings, false 19 | change_column_null :links, :link_errors, false 20 | change_column_null :local_authorities, :link_warnings, false 21 | change_column_null :local_authorities, :link_errors, false 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/20170703155105_add_problem_summary_and_suggested_fix_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddProblemSummaryAndSuggestedFixToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :links, :problem_summary, :string 4 | add_column :links, :suggested_fix, :string 5 | 6 | add_column :local_authorities, :problem_summary, :string 7 | add_column :local_authorities, :suggested_fix, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20170713083505_add_index_on_local_authority_homepage_url.rb: -------------------------------------------------------------------------------- 1 | class AddIndexOnLocalAuthorityHomepageUrl < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :local_authorities, :homepage_url 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170926084949_allow_nil_urls.rb: -------------------------------------------------------------------------------- 1 | class AllowNilUrls < ActiveRecord::Migration[5.0] 2 | def change 3 | change_column_null :links, :url, true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170928143923_add_status_index_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddStatusIndexToLinks < ActiveRecord::Migration[5.0] 2 | def change 3 | add_index :links, :status 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190603131841_enable_service1788.rb: -------------------------------------------------------------------------------- 1 | class EnableService1788 < ActiveRecord::Migration[5.2] 2 | def up 3 | service = Service.find_by(lgsl_code: 1788) 4 | interaction = Interaction.find_by(lgil_code: 8) 5 | 6 | service.update!(enabled: true) 7 | ServiceTier.create_tiers([Tier.district, Tier.unitary, Tier.county], service) 8 | 9 | service_interaction = ServiceInteraction.find_by(service: service, interaction: interaction) 10 | 11 | if service_interaction 12 | service_interaction.update!(live: true) 13 | puts "Successfully enabled service 1788" 14 | else 15 | raise "Service 1788 has not been imported from ESD" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20190617101145_change_tiers1788.rb: -------------------------------------------------------------------------------- 1 | class ChangeTiers1788 < ActiveRecord::Migration[5.2] 2 | def up 3 | service = Service.find_by(lgsl_code: 1788) 4 | interaction = Interaction.find_by(lgil_code: 8) 5 | service_interaction = ServiceInteraction.find_by(service: service, interaction: interaction) 6 | 7 | # This service should be `county/unitary` 8 | ServiceTier.where(service: service, tier_id: Tier.district).destroy_all 9 | 10 | # Remove all links for the service and repopulate without the district authorities 11 | service_interaction.links.destroy_all 12 | LocalLinksManager::Import::MissingLinks.add 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20200406152345_add_index_to_service_tier.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToServiceTier < ActiveRecord::Migration[6.0] 2 | def change 3 | add_index :service_tiers, %i[service_id tier_id], unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200415103445_enable_service1113.rb: -------------------------------------------------------------------------------- 1 | class EnableService1113 < ActiveRecord::Migration[6.0] 2 | def up 3 | service = Service.find_by(lgsl_code: 1113) 4 | interaction = Interaction.find_by(lgil_code: 8) 5 | 6 | service.update!(enabled: true) 7 | ServiceTier.create_tiers([Tier.district, Tier.unitary, Tier.county], service) 8 | 9 | service_interaction = ServiceInteraction.find_by(service:, interaction:) 10 | 11 | if service_interaction 12 | service_interaction.update!(live: true) 13 | else 14 | raise "Service 1113 has not been imported from ESD" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20200501124957_enable_service_1287.rb: -------------------------------------------------------------------------------- 1 | class EnableService1287 < ActiveRecord::Migration[6.0] 2 | def up 3 | service = Service.find_by(lgsl_code: 1287) 4 | interaction = Interaction.find_by(lgil_code: 8) 5 | 6 | service.update!(enabled: true) 7 | ServiceTier.create_tiers([Tier.district, Tier.unitary, Tier.county], service) 8 | 9 | service_interaction = ServiceInteraction.find_by(service:, interaction:) 10 | 11 | if service_interaction 12 | service_interaction.update!(live: true) 13 | else 14 | raise "Service 1287 has not been imported from ESD" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20200604142453_add_default_to_analytics.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultToAnalytics < ActiveRecord::Migration[6.0] 2 | def up 3 | change_column :links, :analytics, :integer, default: 0 4 | end 5 | 6 | def down 7 | change_column :links, :analytics, :integer, default: nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20200605133220_add_constraint_to_analytics.rb: -------------------------------------------------------------------------------- 1 | class AddConstraintToAnalytics < ActiveRecord::Migration[6.0] 2 | def up 3 | change_column_null :links, :analytics, false, 0 4 | end 5 | 6 | def down 7 | change_column_null :links, :analytics, true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20201105085759_enable_service1826.rb: -------------------------------------------------------------------------------- 1 | class EnableService1826 < ActiveRecord::Migration[6.0] 2 | def up 3 | service = Service.find_by(lgsl_code: 1826) 4 | interaction = Interaction.find_by(lgil_code: 8) 5 | 6 | service.update!(enabled: true) 7 | ServiceTier.create_tiers([Tier.district, Tier.unitary, Tier.county], service) 8 | 9 | service_interaction = ServiceInteraction.find_by(service:, interaction:) 10 | 11 | if service_interaction 12 | service_interaction.update!(live: true) 13 | else 14 | raise "Service 1826 has not been imported from ESD" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20201218121339_add_country_name_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddCountryNameToLocalAuthority < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :local_authorities, :country_name, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20211103173354_add_local_custodian_code_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddLocalCustodianCodeToLocalAuthority < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :local_authorities, :local_custodian_code, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20220221141118_update_leicestershire_street_parties_permission.rb: -------------------------------------------------------------------------------- 1 | class UpdateLeicestershireStreetPartiesPermission < ActiveRecord::Migration[7.0] 2 | def up 3 | service = Service.find_by(slug: "street-parties-permission") 4 | 5 | local_authorities = LocalAuthority.where(parent_local_authority: LocalAuthority.find_by(slug: "leicestershire")) 6 | 7 | local_authorities.each do |la| 8 | Link.where(local_authority_id: la.id, service_interaction_id: service.service_interaction_ids).destroy_all 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20230206155322_add_active_end_date_and_active_note_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddActiveEndDateAndActiveNoteToLocalAuthority < ActiveRecord::Migration[7.0] 2 | def change 3 | change_table :local_authorities, bulk: true do |t| 4 | t.datetime :active_end_date, null: true, default: nil 5 | t.string :active_note 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20230307103144_add_succeeded_by_local_authority_id_to_local_authority.rb: -------------------------------------------------------------------------------- 1 | class AddSucceededByLocalAuthorityIdToLocalAuthority < ActiveRecord::Migration[7.0] 2 | def change 3 | add_reference :local_authorities, :succeeded_by_local_authority, type: :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20230504115751_allow_null_snac.rb: -------------------------------------------------------------------------------- 1 | class AllowNullSnac < ActiveRecord::Migration[7.0] 2 | def change 3 | change_column_null :local_authorities, :snac, true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240318105241_add_content_id_to_local_authorities.rb: -------------------------------------------------------------------------------- 1 | class AddContentIdToLocalAuthorities < ActiveRecord::Migration[7.1] 2 | def change 3 | enable_extension "pgcrypto" 4 | 5 | add_column :local_authorities, :content_id, :uuid, default: "gen_random_uuid()" 6 | add_index :local_authorities, :content_id, unique: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20240730142812_modify_services_add_organisation_slug.rb: -------------------------------------------------------------------------------- 1 | class ModifyServicesAddOrganisationSlug < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :services, :organisation_slugs, :string, array: true, default: [] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250424092221_add_title_to_links.rb: -------------------------------------------------------------------------------- 1 | class AddTitleToLinks < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :links, :title, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | 9 | User.find_or_create_by!(name: "Foo", email: "foo@gov.uk") 10 | -------------------------------------------------------------------------------- /docs/checking-links.md: -------------------------------------------------------------------------------- 1 | # Checking Links 2 | 3 | `bundle exec rake check-links` 4 | 5 | This long running rake task performs a `GET` request using [a timeout and a redirect limit](https://github.com/alphagov/local-links-manager/blob/master/lib/local-links-manager/check_links/link_checker.rb#L4L5) against each active link. It stores the HTTP status code of the result (or the error condition that it encountered). The status is shown in the UI to help identify links that need to be fixed. 6 | 7 | A digest of the results is available for interaction links at: 8 | 9 | https://local-links-manager.publishing.service.gov.uk/check_links_status.csv 10 | 11 | and for homepages at: 12 | 13 | https://local-links-manager.publishing.service.gov.uk/check_homepage_links_status.csv 14 | 15 | The output resembles the following: 16 | 17 | | status | count | 18 | | ----------------- | ----- | 19 | | Invalid URI | 231 | 20 | | Connection failed | 524 | 21 | | 500 | 26 | 22 | | 200 | 36599 | 23 | | Too many redirects| 4 | 24 | | 503 | 110 | 25 | | Timeout Error | 194 | 26 | | 401 | 6 | 27 | | 410 | 72 | 28 | | 404 | 2518 | 29 | | SSL Error | 69 | 30 | | 403 | 229 | 31 | -------------------------------------------------------------------------------- /docs/enable-or-create-service.md: -------------------------------------------------------------------------------- 1 | # Enabling (or creating) a new service 2 | 3 | From time to time, we have requests to add new LGSL codes. For this, 4 | you'll need to know the LGSL code, the description of the service and 5 | the providing tier. 6 | 7 | They are imported automatically each night from https://standards.esd.org.uk so 8 | we only need to enable them in this app. 9 | 10 | ## 1. Check that the new service has been imported from ESD 11 | This happens automatically each night, but you can check that there is a `Service` 12 | in the database with the `lgsl_code` you're enabling. 13 | 14 | ## 2. Add to the CSV of enabled services in Publisher: 15 | There is a [CSV file](https://github.com/alphagov/publisher/blob/master/data/local_services.csv) 16 | that contains the LGSL codes that are active on GOV.UK 17 | 18 | To add a new LGSL code: 19 | - Add the code itself in the first column in the CSV file. 20 | - Add the description in the second column. 21 | - Add the providing tier in the third column. 22 | 23 | Providing tiers can be: 24 | - `all` 25 | - `county/unitary` 26 | - `district/unitary` 27 | 28 | After deploying this you will need to run the rake task `local_transactions:fetch_and_clean` 29 | against the Publisher app. 30 | 31 | ## 3. Activate relevant service interactions 32 | You can either do this by creating a local transaction in Publisher, but this 33 | may show the transaction on the live site before the links are ready, so check 34 | with your product manager to make sure this is OK. 35 | 36 | Or use the [`service:enable`](../lib/tasks/service.rb) rake task instead. If 37 | you need to create a new service that has not been defined by the Local 38 | Government Association, you can specify a dummy LGSL code, along with a label 39 | and slug to create a new service. 40 | 41 | ## 4. Add missing links for new service interaction 42 | Run the rake task `import:missing_links`. 43 | 44 | ## 5. Content/department follow-up 45 | Once all the above is done the content team and/or department can follow up by 46 | creating the local transaction in Publisher (if not done previously) and filling 47 | in the missing links in Local Links Manager. 48 | -------------------------------------------------------------------------------- /docs/exporting-local-authority-links.md: -------------------------------------------------------------------------------- 1 | # Exporting Local Authority links to services 2 | 3 | Exports are run daily and produce a `links_to_services_provided_by_local_authorities.csv` 4 | file in the `public/data` directory. This is publicly available at 5 | https://local-links-manager.publishing.service.gov.uk/links-export which 6 | redirects to the static file at `/data/links_to_services_provided_by_local_authorities.csv` 7 | at present. 8 | 9 | A link to this file is published at https://data.gov.uk/dataset/local-authority-services. 10 | They also host a cached, zipped version which is updated daily. 11 | 12 | The file contains the following headers (we tried to keep these as similar to 13 | the localdirect.gov.uk file as possible) : 14 | 15 | | Header | Description | 16 | |--------------------|---------------------------------------------------------------------------| 17 | | Authority Name | The name of the local authority | 18 | | SNAC | The SNAC of the local authority | 19 | | GSS | The GSS code of the local authority | 20 | | Description | The name of the service defined by the combination of LGSL and LGIL codes | 21 | | LGSL | The Local Government Service List code for the service | 22 | | LGIL | The Local Government Interaction List code for the service | 23 | | URL | The URL corresponding to the local authority's provision of the service | 24 | | Supported by GOV.UK| Whether the GOV.UK website uses this link | 25 | 26 | The file is generated by running the rake task: 27 | 28 | `bundle exec rake export:links:all` 29 | -------------------------------------------------------------------------------- /docs/importing-local-authorities-data.md: -------------------------------------------------------------------------------- 1 | # Importing Local Authorities data 2 | 3 | Import all local authorities from `data/local-authorities.csv`: 4 | 5 | `bundle exec rake import:local_authorities:import_all` 6 | 7 | Then import services and interactions: 8 | 9 | `bundle exec rake import:service_interactions:import_all` 10 | 11 | ## Blank CSVs 12 | 13 | Sometimes a local authority will ask for a blank CSV they can fill in which contains all the links supported for a particular level. You can get a blank CSV by running one of these tasks locally: 14 | 15 | `bundle exec rake export:blank_csv[county]` 16 | 17 | `bundle exec rake export:blank_csv[district]` 18 | 19 | `bundle exec rake export:blank_csv[unitary]` 20 | -------------------------------------------------------------------------------- /docs/importing-service-links-from-csv.md: -------------------------------------------------------------------------------- 1 | # Importing Service Links from a CSV 2 | 3 | `bundle exec rake import:service_links[lgsl_code lgil_code filename]` provides the ability to mass import local authority service links. 4 | 5 | ## Why? 6 | 7 | This is useful in a situation where a new service is added nationally. Every local authority running the new service will have a page on their website to provide information about the service. Usually the url would be added for a service through the user interface. 8 | 9 | ### Data format 10 | 11 | A CSV with the headings `slug` and `url` needs to be placed somewhere it can be read in by the script (accessed by the `filename` argument). 12 | 13 | Example: 14 | 15 | `test-data.csv` 16 | 17 | ``` 18 | slug,url 19 | milton-keynes,http://www.milton-keynes.gov.uk/new-service 20 | brighton-and-hove,http://www.brighton-hove.gov.uk/new-service 21 | ``` 22 | 23 | ### Running the script 24 | 25 | `govuk-docker-run bundle exec rake import:service_links[123,8,test-data.csv]` 26 | 27 | 28 | To run on anything other than a local environment, the CSV needs to be added manually to the `/tmp` directory using `scp-push`. If there are multiple machine classes, the CSV will need to be added to the `/tmp` directory for each of them. 29 | 30 | ``` 31 | $ gds govuk connect scp-push --environment [integration|staging|integration] name-of-machine[:1|:2|:3] path/to/file.ext /tmp/ 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/local-authority-changes.md: -------------------------------------------------------------------------------- 1 | # Local Authority Changes 2 | 3 | Local Links Manager is set up to edit links, and editing other aspects of Local Authorities is not really supported by the front end. Fortunately these changes do not happen often, but when they do they tend to happen in batches. 4 | 5 | ## To change a Local Authority's details 6 | 7 | The URL can be changed in the UI, but if you need to change the slug and/or authority name, you'll have to log on to the app-console to do it. 8 | 9 | ## To mark a Local Authority as superseded 10 | 11 | Sometimes local authorities are merged (this tends to be borough/city/district councils getting rolled up into their county council, which then becomes a unitary authority). In this case, you should create a rake task that does the following: 12 | 13 | - Update the county council from a tier 2 (county) to a tier 3 (unitary) council (or create a new unitary council if repurposing the previous one is likely to cause problems). 14 | - For each of the councils being merged: 15 | - Set the active_end_date property to the date on which it will be (or aws, if you're doing this in retrospect) merged into the unitary authority. 16 | - Set the active_note property with a text description of the merge. 17 | - Set the succeeded_by_local_authority property to point to the new unitary council. 18 | - (Only after the council has been merged) Download the links CSV file and attach it to the card describing the change. 19 | - (Only after the council has been merged) Delete all the Link items attached to the council so that the link checker will not attempt to check them, and they will not appear in the broken links report. 20 | 21 | Note that new Local Authorities do not have SNAC codes, only GSS codes. 22 | 23 | -------------------------------------------------------------------------------- /docs/permissions.md: -------------------------------------------------------------------------------- 1 | # Permissions 2 | 3 | ## Named Permissions 4 | 5 | - `signin`: as with other apps, this is the basic permission needed to access 6 | the app. 7 | - `GDS Editor`: gives the user permission to do all actions in the app. 8 | 9 | ## Department Permissions 10 | 11 | Other permissions are based on the organisation_slug of the current user. If a 12 | user does not have the `GDS Editor` permission, they will be able to: 13 | 14 | - visit the Services page `/services`. Only services with the current user's 15 | organisation slug in the service's organisation_slugs array will be visible. 16 | - visit the specific service pages of those services 17 | - download a csv of links for those services 18 | - upload a csv including new links for those services 19 | - edit links in those services. 20 | 21 | The organisation slugs array is editable only by people with the `GDS Editor` 22 | permission. 23 | -------------------------------------------------------------------------------- /docs/service-owners.md: -------------------------------------------------------------------------------- 1 | # Service Owners 2 | 3 | Most services currently do not have an owner, but a service can be assigned one or more owning organisations by a user with the `GDS Editor` [permission](/docs/permissions.md). Visit the service, and select the "Update Owner" action from the sidebar. You can then enter one or more organisations by their organisation slug (the final part of their organisation URL on gov.uk, eg: 'government-digital-service' from https://www.gov.uk/government/organisations/government-digital-service), separating organisations with a space if there are more than one. 4 | 5 | This allows users from that department with access to Local Links Manager to edit the service as detailed in the [Permissions page](/docs/permissions.md) 6 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/check-links/link_checker.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/distributed_lock" 2 | require_relative "../../../app/lib/local_links_manager/check_links/link_status_requester" 3 | 4 | desc "Check all links for enabled services" 5 | task "check-links": :environment do 6 | LocalLinksManager::DistributedLock.new("check-links").lock( 7 | lock_obtained: lambda { 8 | begin 9 | Rails.logger.info("Lock obtained, starting link checker") 10 | LocalLinksManager::CheckLinks::LinkStatusRequester.new.call 11 | Rails.logger.info("Link checker completed") 12 | rescue StandardError => e 13 | Rails.logger.error("Error while running link checker\n#{e}") 14 | raise e 15 | end 16 | }, 17 | lock_not_obtained: lambda { 18 | Rails.logger.info("Unable to lock") 19 | }, 20 | ) 21 | end 22 | 23 | namespace :"check-links" do 24 | desc "Check links & update link status for a single local authority" 25 | task :local_authority, [:authority_slug] => :environment do |_, args| 26 | checker = LocalLinksManager::CheckLinks::LinkStatusRequester.new 27 | checker.check_authority_urls(args[:authority_slug]) 28 | end 29 | 30 | desc <<~DESC 31 | Check links & update link status for all local authorities. 32 | This will run in the background as a queue of Sidekiq jobs. 33 | The jobs will take a long time to complete, even a few hours. 34 | DESC 35 | task all_local_authorities: :environment do 36 | checker = LocalLinksManager::CheckLinks::LinkStatusRequester.new 37 | LocalAuthority.all.find_each { |la| checker.check_authority_urls(la.slug) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/tasks/export/analytics_bad_links_exporter.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/export/analytics_exporter" 2 | 3 | namespace :export do 4 | namespace :google_analytics do 5 | desc "Export bad links status to Google Analytics" 6 | task "bad_links": :environment do 7 | if ENV["RUN_LINK_GA_EXPORT"].present? && ENV["RUN_LINK_GA_EXPORT"] == "true" 8 | LocalLinksManager::DistributedLock.new("bad-links-analytics-export").lock( 9 | lock_obtained: lambda { 10 | begin 11 | Rails.logger.info("Starting link exporter") 12 | 13 | LocalLinksManager::Export::AnalyticsExporter.export 14 | 15 | Rails.logger.info("Bad links export to GA has completed") 16 | rescue StandardError => e 17 | Rails.logger.error("Error while running link exporter\n#{e}") 18 | raise e 19 | end 20 | }, 21 | lock_not_obtained: lambda { 22 | }, 23 | ) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/tasks/export/blank_csv_exporter.rake: -------------------------------------------------------------------------------- 1 | require "csv" 2 | 3 | namespace :export do 4 | desc "Export blank CSV for new authority" 5 | task "blank_csv", %i[tier_name] => :environment do |_, args| 6 | abort "Tier name must be one of: district, county, unitary" unless %w[district county unitary].include?(args.tier_name) 7 | 8 | CSV.open("blank_file_#{args.tier_name}.csv", "wb") do |csv| 9 | csv << ["Authority Name", "GSS", "Description", "LGSL", "LGIL", "URL", "Title", "Supported by GOV.UK", "Status", "New URL", "New Title"] 10 | Service.enabled.each do |service| 11 | next unless service.tiers.include?(args.tier_name) 12 | 13 | service.interactions.each do |interaction| 14 | description = "#{service.label}: #{interaction.label}" 15 | csv << ["You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |If you are the application owner check the logs for more information.
64 |