├── .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 |
9 | <%= yield %> 10 |
11 | <% end %> 12 | 13 | <%= render "govuk_publishing_components/components/layout_for_admin", { 14 | environment:, 15 | product_name: "Local Links Manager", 16 | browser_title: (yield :page_title), 17 | } do %> 18 | 19 | <% 20 | menu_option = [] 21 | 22 | if gds_editor? 23 | menu_option += [{ 24 | text: "Broken Links", 25 | href: root_path, 26 | active: current_page?(root_path), 27 | }, 28 | { 29 | text: "Councils", 30 | href: local_authorities_path(filter: %w[only_active]), 31 | active: current_page?(local_authorities_path), 32 | }] 33 | end 34 | 35 | menu_option << { 36 | text: "Services", 37 | href: services_path, 38 | active: current_page?(services_path), 39 | } 40 | 41 | menu_option << { 42 | text: "Switch app", 43 | href: Plek.external_url_for("signon"), 44 | } 45 | %> 46 | 47 | <%= render "govuk_publishing_components/components/layout_header", { 48 | product_name: "Local Links Manager", 49 | environment:, 50 | navigation_items: menu_option, 51 | } %> 52 | 53 |
54 | 55 | <%= render "govuk_publishing_components/components/breadcrumbs", { 56 | collapse_on_mobile: true, 57 | breadcrumbs: @breadcrumbs, 58 | } %> 59 | 60 |
61 | <%= yield :body %> 62 |
63 | 64 |
65 | 66 | <%= render "govuk_publishing_components/components/layout_footer", { } %> 67 | 68 | <% end %> 69 | -------------------------------------------------------------------------------- /app/views/links/_link_status_alerts.html.erb: -------------------------------------------------------------------------------- 1 | <% pres = LinkPresenter.new(link) %> 2 | <% last_message = alerts.pop %> 3 | <%= render "govuk_publishing_components/components/inset_text", { 4 | } do %> 5 |
6 | <% alerts.each do |message| %> 7 |

<%= 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 |
15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/links/_link_table_row.html.erb: -------------------------------------------------------------------------------- 1 | <%= content_tag "tr", 2 | class: link.updated? ? "success" : "", 3 | data: link.row_data do %> 4 | <%= link.analytics.to_i %>visits this week 5 | 6 |
<%= link.local_authority.name %>
7 |
<%= link.govuk_title || link.service_label %> 8 | code: <%= link.service_interaction.lgsl_code %> 9 |
10 | 18 | 19 | 20 |
21 | <%= link.status_description %> 22 |
23 | <%= link.last_checked %> 24 | 25 | 26 | <%= link_to("Edit link", link.edit_path, class: "btn btn-default btn-lg") %> 27 | 28 | <% end %> 29 | -------------------------------------------------------------------------------- /app/views/links/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, "Broken Links" %> 2 | <%= render partial: "shared/flash" %> 3 | 4 | <%= render "govuk_publishing_components/components/heading", { 5 | text: "Broken Links (showing top 200 of #{@total_broken_links})", 6 | heading_level: 1, 7 | font_size: "l", 8 | margin_bottom: 5, 9 | } %> 10 | 11 | <%= render partial: "shared/links_table", locals: { links_table_presenter: LinksTablePresenter.new(@broken_links, self) } %> 12 | -------------------------------------------------------------------------------- /app/views/local_authorities/_filter.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= form_with url: local_authorities_path, method: :get do |form| %> 4 | <%= render "govuk_publishing_components/components/option_select", { 5 | options_container_id: "list_of_vegetables", 6 | title: "Only show", 7 | key: "filter", 8 | options: [ 9 | { 10 | value: "only_active", 11 | label: "Active councils", 12 | checked: params[:filter]&.include?("only_active"), 13 | }, 14 | { 15 | value: "only_homepage_problems", 16 | label: "Homepage problems", 17 | checked: params[:filter]&.include?("only_homepage_problems"), 18 | }, 19 | ], 20 | } %> 21 | 22 | <%= render "govuk_publishing_components/components/button", { 23 | text: "Update", 24 | margin_bottom: 4, 25 | data_attributes: { 26 | module: "gem-track-click", 27 | "track-category": "form-button", 28 | "track-action": "local-authority-filter-button", 29 | "track-label": "Update", 30 | }, 31 | } %> 32 | <% end %> 33 | -------------------------------------------------------------------------------- /app/views/local_authorities/download_links_form.html.erb: -------------------------------------------------------------------------------- 1 | <% title = "Download links for #{@authority.name}" %> 2 | <% form_path = download_links_csv_local_authority_path(@authority) %> 3 | 4 | <% content_for :page_title, title %> 5 | 6 | <%= render partial: "shared/download_links_form", locals: { form_path:, title: } %> 7 | -------------------------------------------------------------------------------- /app/views/local_authorities/edit_url.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, "Editing #{@authority.name}" %> 2 | 3 |
4 |
5 | 6 | <%= render "govuk_publishing_components/components/heading", { 7 | text: "Editing #{@authority.name}", 8 | heading_level: 1, 9 | font_size: "l", 10 | margin_bottom: 5, 11 | } %> 12 | 13 | <%= form_for(@authority) do %> 14 | <%= render "govuk_publishing_components/components/input", { 15 | label: { 16 | text: "Homepage URL", 17 | }, 18 | name: "homepage_url", 19 | autofocus: true, 20 | tabindex: 0, 21 | value: @authority.homepage_url, 22 | } %> 23 | 24 | <%= render "govuk_publishing_components/components/button", { text: "Update" } %> 25 | <% end %> 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /app/views/local_authorities/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, "Councils" %> 2 | 3 | <% breadcrumb :local_authorities %> 4 | 5 | <%= render "govuk_publishing_components/components/heading", { 6 | text: "Councils (#{@authorities.count})", 7 | heading_level: 1, 8 | font_size: "l", 9 | margin_bottom: 5, 10 | } %> 11 | 12 |
13 |
14 | <%= render "filter" %> 15 |
16 | 17 |
18 | <% local_authorities_table_presenter = LocalAuthoritiesTablePresenter.new(@authorities, self) %> 19 | <%= render "govuk_publishing_components/components/table", { 20 | filterable: true, 21 | label: "Filter Council Names", 22 | head: local_authorities_table_presenter.headers, 23 | rows: local_authorities_table_presenter.rows, 24 | } %> 25 |
26 |
27 | -------------------------------------------------------------------------------- /app/views/local_authorities/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, @authority.name %> 2 | <%= render partial: "shared/flash" %> 3 | 4 | <%= render "govuk_publishing_components/components/heading", { 5 | text: @authority.name, 6 | heading_level: 1, 7 | font_size: "l", 8 | margin_bottom: 5, 9 | } %> 10 | 11 |
12 |
13 | <%= render "govuk_publishing_components/components/summary_list", LocalAuthorityPresenter.new(@authority).summary_list(self) %> 14 |
15 |
16 | 31 |
32 |
33 | 34 | <%= render partial: "shared/links_table", locals: { links_table_presenter: LinksTablePresenter.new(@links, self, remove_council: true) } %> 35 | -------------------------------------------------------------------------------- /app/views/local_authorities/upload_links_form.html.erb: -------------------------------------------------------------------------------- 1 | <% title = "Upload links for #{@authority.name}" %> 2 | <% form_path = upload_links_csv_local_authority_path(@authority) %> 3 | 4 | <% content_for :page_title, title %> 5 | 6 | <%= render partial: "shared/flash" %> 7 | 8 | <%= render partial: "shared/upload_links_form", locals: { form_path:, title: } %> 9 | -------------------------------------------------------------------------------- /app/views/services/_local_authority.html.erb: -------------------------------------------------------------------------------- 1 | <% links_by_authority = @links[local_authority.id] %> 2 | <% unless links_by_authority.blank? %> 3 | <%= render partial: "service_links_by_authority", locals: { local_authority: local_authority, links_by_authority: links_by_authority } %> 4 | <% end %> 5 | -------------------------------------------------------------------------------- /app/views/services/_service_links_by_authority.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |

<%= link_to local_authority.name, local_authority %>

4 | 5 | 6 | <% first_link, *remaining_links = links_by_authority %> 7 | <%= render "shared/link_table_row", link: ServiceLinkPresenter.new(first_link, view_context: self, first: true) %> 8 | <% remaining_links.each do |link| %> 9 | <%= render "shared/link_table_row", link: ServiceLinkPresenter.new(link, view_context: self, first: false) %> 10 | <% end %> 11 | -------------------------------------------------------------------------------- /app/views/services/download_links_form.html.erb: -------------------------------------------------------------------------------- 1 | <% title = "Download links for #{@service.label}" %> 2 | <% form_path = download_links_csv_service_path(@service) %> 3 | 4 | <% content_for :page_title, title %> 5 | 6 | <%= render partial: "shared/download_links_form", locals: { form_path:, title: } %> 7 | -------------------------------------------------------------------------------- /app/views/services/index.html.erb: -------------------------------------------------------------------------------- 1 | <% title = gds_editor? ? "Services" : "Services for #{org_name_for_current_user}" %> 2 | <% content_for :page_title, title %> 3 | 4 | <% breadcrumb :services %> 5 | 6 | <%= render "govuk_publishing_components/components/heading", { 7 | text: "#{title} (#{@services.count})", 8 | heading_level: 1, 9 | font_size: "l", 10 | margin_bottom: 5, 11 | } %> 12 | 13 | <% services_table_presenter = ServicesTablePresenter.new(@services, self) %> 14 | 15 | <%= render "govuk_publishing_components/components/table", { 16 | filterable: true, 17 | label: "Filter Services", 18 | head: services_table_presenter.headers, 19 | rows: services_table_presenter.rows, 20 | } %> 21 | -------------------------------------------------------------------------------- /app/views/services/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, @service.label %> 2 | <%= render partial: "shared/flash" %> 3 | 4 | <%= render "govuk_publishing_components/components/heading", { 5 | text: @service.label, 6 | heading_level: 1, 7 | font_size: "l", 8 | margin_bottom: 5, 9 | } %> 10 | 11 |
12 |
13 | <%= render "govuk_publishing_components/components/summary_list", ServicePresenter.new(@service).summary_list(self) %> 14 |
15 |
16 | 33 |
34 |
35 | 36 | <%= render partial: "shared/links_table", locals: { links_table_presenter: LinksTablePresenter.new(@links, self, remove_service: true) } %> 37 | -------------------------------------------------------------------------------- /app/views/services/update_owner_form.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, "Update Owner" %> 2 | 3 | <%= render "govuk_publishing_components/components/heading", { 4 | text: "Update Owner", 5 | heading_level: 1, 6 | font_size: "l", 7 | margin_bottom: 5, 8 | } %> 9 | 10 | <%= form_for(@service, url: update_owner_service_path(@service)) do %> 11 | <%= render "govuk_publishing_components/components/input", { 12 | label: { text: "Organisation Slugs" }, 13 | hint: "For multiple owning organisations, list slugs separated by spaces", 14 | name: "service[organisation_slugs]", 15 | value: @service&.organisation_slugs, 16 | } %> 17 | <%= render "govuk_publishing_components/components/button", { text: "Submit" } %> 18 | <%= render "govuk_publishing_components/components/button", { 19 | text: "Cancel", 20 | secondary_quiet: true, 21 | href: service_path(@service, filter: "broken_links"), 22 | } %> 23 | <% end %> 24 | -------------------------------------------------------------------------------- /app/views/services/upload_links_form.html.erb: -------------------------------------------------------------------------------- 1 | <% title = "Upload links for #{@service.label}" %> 2 | <% form_path = upload_links_csv_service_path(@service) %> 3 | 4 | <% content_for :page_title, title %> 5 | 6 | <%= render partial: "shared/flash" %> 7 | <%= render partial: "shared/upload_links_form", locals: { form_path:, title: } %> 8 | -------------------------------------------------------------------------------- /app/views/shared/_download_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 |

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 << ["", "", description, service.lgsl_code, interaction.lgil_code, "", "", "TRUE", "missing", "", ""] 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/tasks/export/link_exporter.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/export/links_exporter" 2 | 3 | require "aws-sdk-s3" 4 | 5 | namespace :export do 6 | namespace :links do 7 | desc "Export links to CSV and upload to S3" 8 | task "s3": :environment do 9 | filename = "links_to_services_provided_by_local_authorities.csv" 10 | 11 | bucket = ENV["AWS_S3_ASSET_BUCKET_NAME"] 12 | key = "data/local-links-manager/#{filename}" 13 | 14 | s3 = Aws::S3::Client.new 15 | 16 | StringIO.open do |body| 17 | LocalLinksManager::Export::LinksExporter.new.export(body) 18 | 19 | s3.put_object({ body: body.string, bucket:, key: }) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tasks/export/links_status_csv_exporter.rake: -------------------------------------------------------------------------------- 1 | require "csv" 2 | require "aws-sdk-s3" 3 | 4 | namespace :export do 5 | namespace :links do 6 | desc "Generate links status to CSV and upload to S3" 7 | task "status": :environment do 8 | filename = "links_with_local_authority_service.csv" 9 | 10 | bucket = ENV["AWS_S3_ASSET_BUCKET_NAME"] 11 | key = "data/local-links-manager/#{filename}" 12 | 13 | s3 = Aws::S3::Client.new 14 | 15 | StringIO.open do |body| 16 | output = CSV.generate do |csv| 17 | csv << ["Link", "Local Authority", "Service", "Status", "Problem Summary"] 18 | Link.joins(:local_authority, :service) 19 | .select("links.url AS link_url", "local_authorities.name AS local_authority_name", "services.label AS service_name", "links.status AS link_status", "links.problem_summary AS link_problem_summary") 20 | .each do |link| 21 | csv << [link.link_url, link.local_authority_name, link.service_name, link.link_status, link.link_problem_summary] 22 | end 23 | end 24 | body.write(output) 25 | 26 | s3.put_object({ body: body.string, bucket:, key: }) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/tasks/import/all.rake: -------------------------------------------------------------------------------- 1 | namespace :import do 2 | desc "Runs all the imports required to set up a functioning database - in the right order" 3 | task all: :environment do 4 | Rake::Task["import:local_authorities:import_all"].invoke 5 | Rake::Task["import:service_interactions:import_all"].invoke 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/import/google_analytics.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/import/analytics_importer" 2 | 3 | namespace :import do 4 | desc "Imports analytics so that links can be prioritised by usage" 5 | task google_analytics: :environment do 6 | LocalLinksManager::DistributedLock.new("analytics-import").lock( 7 | lock_obtained: lambda { 8 | begin 9 | LocalLinksManager::Import::AnalyticsImporter.import 10 | rescue StandardError => e 11 | raise e 12 | end 13 | }, 14 | lock_not_obtained: lambda { 15 | }, 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tasks/import/local_authorities.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/distributed_lock" 2 | require_relative "../../../app/lib/local_links_manager/import/local_authorities_importer" 3 | 4 | namespace :import do 5 | namespace :local_authorities do 6 | desc "Import local authority names, codes and tiers from CSV" 7 | task import_all: :environment do 8 | LocalLinksManager::Import::LocalAuthoritiesImporter.import_from_csv(File.expand_path("../../../data/local-authorities.csv", File.dirname(__FILE__))) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/import/missing_links.rake: -------------------------------------------------------------------------------- 1 | require_relative "../../../app/lib/local_links_manager/import/missing_links" 2 | 3 | namespace :import do 4 | desc "Add missing links for links that are missing" 5 | task missing_links: :environment do 6 | LocalLinksManager::Import::MissingLinks.add 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tasks/import/service_links_from_csv.rake: -------------------------------------------------------------------------------- 1 | require "csv" 2 | 3 | namespace :import do 4 | desc "Imports service links from CSV file" 5 | task :service_links, %i[lgsl_code lgil_code filename] => :environment do |_, args| 6 | service_interaction = ServiceInteraction.find_or_create_by!( 7 | service: Service.find_by!(lgsl_code: args.lgsl_code), 8 | interaction: Interaction.find_by!(lgil_code: args.lgil_code), 9 | ) 10 | 11 | csv = CSV.read(args.filename, headers: true, encoding: "bom|utf-8") 12 | 13 | puts "Importing [#{csv.count}] links" 14 | imported = 0 15 | 16 | csv.each do |row| 17 | slug = row["slug"]&.strip 18 | url = row["url"]&.strip 19 | 20 | local_authority = LocalAuthority.find_by(slug:) 21 | 22 | local_link = Link.find_or_initialize_by( 23 | local_authority:, 24 | service_interaction:, 25 | ) 26 | 27 | local_link.url = url 28 | local_link.save! 29 | 30 | imported += 1 31 | end 32 | 33 | puts "[#{imported}] links imported" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/tasks/lint.rake: -------------------------------------------------------------------------------- 1 | desc "Run all linters" 2 | task lint: :environment do 3 | sh "bundle exec rubocop" 4 | if Rails.env.development? 5 | sh "bundle exec erb_lint --lint-all --autocorrect" 6 | else 7 | sh "bundle exec erb_lint --lint-all" 8 | end 9 | sh "yarn lint" 10 | end 11 | -------------------------------------------------------------------------------- /lib/tasks/remove_lock.rake: -------------------------------------------------------------------------------- 1 | require "redis-lock" 2 | 3 | desc "Unlock lock" 4 | task :unlock, [] => :environment do |_task, args| 5 | if args.extras.empty? 6 | puts "Pass in a lock key. Eg. unlock['check-links']" 7 | else 8 | lock = LocalLinksManager::DistributedLock.new(args.extras.first.to_s) 9 | if lock.locked? 10 | lock.unlock 11 | puts "#{args.extras.first} successfully unlocked." 12 | else 13 | puts "No lock exists for #{args.extras.first}" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tasks/service.rake: -------------------------------------------------------------------------------- 1 | namespace :service do 2 | desc "Enable or create a service." 3 | task :enable, %i[lgsl lgil label slug] => [:environment] do |_, args| 4 | lgsl = args.lgsl 5 | # LGIL is deprecated concept, defaults to PROVIDING INFORMATION 6 | lgil = args.lgil || Interaction::PROVIDING_INFORMATION_LGIL 7 | 8 | # Provide a label and slug if creating a service that doesn't exist 9 | label = args.label 10 | slug = args.slug 11 | 12 | service = Service.find_by(lgsl_code: lgsl) 13 | 14 | if slug && label && service.nil? 15 | service = Service.create!( 16 | lgsl_code: lgsl, 17 | label:, 18 | slug:, 19 | ) 20 | end 21 | abort "Service [#{lgsl}] does not exist" unless service 22 | 23 | interaction = Interaction.find_by(lgil_code: lgil) 24 | abort "Interaction [#{lgil}] does not exist" unless interaction 25 | 26 | service.update!(enabled: true) 27 | 28 | # Creating all service tiers (check if your service requires all) 29 | ServiceTier.create_tiers([Tier.district, Tier.unitary, Tier.county], service) 30 | 31 | service_interaction = ServiceInteraction.find_or_create_by!( 32 | service:, 33 | interaction:, 34 | ) 35 | abort "Service Interaction between [#{lgsl}] and [#{lgil}] does not exist" unless service_interaction 36 | 37 | service_interaction.update!(live: true) 38 | end 39 | 40 | desc "Destroys an existing Service and all dependant records" 41 | task :destroy, %w[lgsl_code] => :environment do |_, args| 42 | service = Service.find_by!(lgsl_code: args.lgsl_code) 43 | service.destroy! 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-links-manager", 3 | "description": "Admin application for GOV.UK", 4 | "private": true, 5 | "author": "Government Digital Service", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "yarn run lint:js && yarn run lint:scss", 9 | "lint:js": "standardx 'app/assets/javascripts/**/*.js'", 10 | "lint:scss": "stylelint app/assets/stylesheets/" 11 | }, 12 | "standardx": { 13 | "env": { 14 | "browser": true 15 | } 16 | }, 17 | "eslintConfig": { 18 | "rules": { 19 | "no-var": 0 20 | } 21 | }, 22 | "stylelint": { 23 | "extends": ["stylelint-config-gds/scss", "stylelint-stylistic/config"] 24 | }, 25 | "devDependencies": { 26 | "standardx": "^7.0.0", 27 | "stylelint": "^15.11.0", 28 | "stylelint-config-gds": "^1.1.1", 29 | "stylelint-stylistic": "^0.4.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/public/data/.keep -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /spec/factories/clicks_response_factory.rb: -------------------------------------------------------------------------------- 1 | module GoogleAnalytics 2 | class ClicksResponseFactory 3 | def self.build(responses) 4 | Google::Apis::AnalyticsreportingV4::GetReportsResponse.new( 5 | reports: [ 6 | Google::Apis::AnalyticsreportingV4::Report.new( 7 | data: Google::Apis::AnalyticsreportingV4::ReportData.new( 8 | rows: 9 | responses.map do |response| 10 | Google::Apis::AnalyticsreportingV4::ReportRow.new( 11 | dimensions: [ 12 | response.fetch(:base_path), 13 | response.fetch(:local_link), 14 | ], 15 | metrics: [ 16 | Google::Apis::AnalyticsreportingV4::DateRangeValues.new( 17 | values: [ 18 | response.fetch(:clicks), 19 | ], 20 | ), 21 | ], 22 | ) 23 | end, 24 | ), 25 | ), 26 | ], 27 | ) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/factories/interactions.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :interaction do 3 | sequence(:lgil_code) { |n| n } 4 | sequence(:label) { |n| "Interaction Label #{n}" } 5 | slug { label.parameterize } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/links.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :link do 3 | association :local_authority 4 | association :service_interaction 5 | sequence(:url) { |n| "http://www.example.com/#{n}" } 6 | status { nil } 7 | link_last_checked { nil } 8 | analytics { 0 } 9 | title { nil } 10 | end 11 | 12 | factory :ok_link, parent: :link do 13 | status { "ok" } 14 | end 15 | 16 | factory :broken_link, parent: :link do 17 | sequence(:url) { |n| "hhhttttttppp://www.example.com/broken-#{n}" } 18 | status { "broken" } 19 | end 20 | 21 | factory :caution_link, parent: :link do 22 | status { "caution" } 23 | end 24 | 25 | factory :missing_link, parent: :link do 26 | url { nil } 27 | status { "missing" } 28 | end 29 | 30 | factory :pending_link, parent: :link do 31 | status { "pending" } 32 | end 33 | 34 | factory :link_for_disabled_service, parent: :link do 35 | after(:create) do |link| 36 | link.service.update(enabled: false) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/factories/local_authorities.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :local_authority do 3 | sequence(:name) { |n| "Local Authority Name #{n}" } 4 | sequence(:gss) { |n| sprintf("S%08i", n:) } 5 | sequence(:snac) { |n| sprintf("%02iQC", n:) } 6 | sequence(:local_custodian_code) { |n| sprintf("%04i", n:) } 7 | tier_id { Tier.unitary } 8 | slug { name.parameterize } 9 | homepage_url { "http://www.angus.gov.uk" } 10 | status { nil } 11 | link_last_checked { nil } 12 | country_name { "England" } 13 | end 14 | 15 | factory :district_council, parent: :local_authority do 16 | tier_id { Tier.district } 17 | end 18 | 19 | factory :unitary_council, parent: :local_authority do 20 | tier_id { Tier.unitary } 21 | end 22 | 23 | factory :county_council, parent: :local_authority do 24 | tier_id { Tier.county } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/factories/service_interactions.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :service_interaction do 3 | association :interaction 4 | association :service, :all_tiers 5 | govuk_slug { "a-slug" } 6 | govuk_title { "A title" } 7 | live { false } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/services.rb: -------------------------------------------------------------------------------- 1 | def create_factory_service_tiers(service, tiers) 2 | tiers.each do |tier_id| 3 | service.service_tiers << ServiceTier.create(service:, tier_id:) 4 | end 5 | end 6 | 7 | FactoryBot.define do 8 | factory :service do 9 | sequence(:lgsl_code) { |n| n } 10 | sequence(:label) { |n| "Service Label #{n}" } 11 | slug { label.parameterize } 12 | enabled { true } 13 | 14 | trait :all_tiers do 15 | sequence(:label) { |n| "All Tiers #{n}" } 16 | after(:create) do |service| 17 | create_factory_service_tiers(service, [Tier.unitary, Tier.district, Tier.county]) 18 | end 19 | end 20 | 21 | trait :district_unitary do 22 | sequence(:label) { |n| "District/Unitary #{n}" } 23 | after(:create) do |service| 24 | create_factory_service_tiers(service, [Tier.unitary, Tier.district]) 25 | end 26 | end 27 | 28 | trait :county_unitary do 29 | sequence(:label) { |n| "County/Unitary #{n}" } 30 | after(:create) do |service| 31 | create_factory_service_tiers(service, [Tier.unitary, Tier.county]) 32 | end 33 | end 34 | end 35 | 36 | factory :disabled_service, parent: :service do 37 | enabled { false } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :user do 3 | name { "New User" } 4 | email { "user@email.com" } 5 | organisation_slug { "test-department" } 6 | permissions { %w[signin] } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/features/local_authorities/local_authority_download_links_spec.rb: -------------------------------------------------------------------------------- 1 | feature "The local authority download CSV page" do 2 | let!(:local_authority) { create(:district_council) } 3 | 4 | before do 5 | login_as_gds_editor 6 | visit local_authority_path(local_authority_slug: local_authority.slug) 7 | 8 | service = create(:service, :all_tiers, label: "OK Service") 9 | service_interaction = create(:service_interaction, service:) 10 | create(:link, local_authority:, service_interaction:, status: :ok) 11 | 12 | service = create(:service, :all_tiers, label: "Broken Service") 13 | service_interaction = create(:service_interaction, service:) 14 | create(:link, local_authority:, service_interaction:, status: :broken) 15 | 16 | click_on "Download Links" 17 | end 18 | 19 | describe "CSV download" do 20 | it "downloads a CSV" do 21 | find("#content").click_on "Download Links" 22 | 23 | expect(page.response_headers["Content-Type"]).to eq("text/csv") 24 | end 25 | 26 | context "when user leaves all link status checkboxes selected (by default)" do 27 | it "all services are in the CSV" do 28 | find("#content").click_on "Download Links" 29 | 30 | expect(page.text).to include("OK Service") 31 | expect(page.text).to include("Broken Service") 32 | end 33 | end 34 | 35 | context "when user unchecks one of the boxes" do 36 | it "only checked services are in the CSV" do 37 | find("#content").uncheck "Ok" 38 | find("#content").click_on "Download Links" 39 | 40 | expect(page.text).not_to include("OK Service") 41 | expect(page.text).to include("Broken Service") 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/features/local_authorities/local_authority_upload_links_spec.rb: -------------------------------------------------------------------------------- 1 | feature "The local authority upload CSV page" do 2 | let!(:local_authority) { create(:district_council) } 3 | let(:test_authority_path) { local_authority_path(local_authority_slug: local_authority.slug) } 4 | 5 | before do 6 | login_as_gds_editor 7 | 8 | visit test_authority_path 9 | 10 | service = create(:service, :all_tiers, label: "OK Service") 11 | service_interaction = create(:service_interaction, service:) 12 | create(:link, local_authority:, service_interaction:, status: :ok) 13 | 14 | service = create(:service, :all_tiers, label: "Broken Service") 15 | service_interaction = create(:service_interaction, service:) 16 | create(:link, local_authority:, service_interaction:, status: :broken) 17 | 18 | click_on "Upload Links" 19 | end 20 | 21 | describe "Empty upload" do 22 | it "returns to the local authority page" do 23 | find("#content").click_on "Upload Links" 24 | 25 | expect(page.current_path).to eq(upload_links_form_local_authority_path(local_authority)) 26 | expect(page.body).to include("A CSV file must be provided.") 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/features/services/service_index_spec.rb: -------------------------------------------------------------------------------- 1 | feature "The services index page" do 2 | before do 3 | login_as_gds_editor 4 | 5 | @aardvark = create(:service, label: "Aardvark Wardens") 6 | @zebra = create(:service, label: "Zebra Fouling", broken_link_count: 1) 7 | si = create(:service_interaction, service: @aardvark, govuk_title: "SI1") 8 | create(:link, service_interaction: si, analytics: 30) 9 | si2 = create(:service_interaction, service: @aardvark, govuk_title: "SI2") 10 | create(:link, service_interaction: si2, analytics: 25) 11 | create(:disabled_service) 12 | visit services_path 13 | end 14 | 15 | it "has a breadcrumb trail" do 16 | expect(page).to have_selector(".govuk-breadcrumbs__list") 17 | end 18 | 19 | it "displays a filter box" do 20 | expect(page).to have_selector(".js-gem-c-table__filter") 21 | end 22 | 23 | it "shows enabled services sorted by broken link count" do 24 | expect(page).to have_content("Services (2)") 25 | expect(page).to have_content("0 Zebra Fouling Not used on GOV.UK #{@zebra.lgsl_code} 1") 26 | expect(page).to have_content("55 Aardvark Wardens SI1SI2 #{@aardvark.lgsl_code} 0") 27 | expect("Zebra Fouling").to appear_before("Aardvark Wardens") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/fixtures/service-links.csv: -------------------------------------------------------------------------------- 1 | slug,url 2 | angus,https://www.example.com/new-service 3 | -------------------------------------------------------------------------------- /spec/helpers/application_helper_spec.rb: -------------------------------------------------------------------------------- 1 | describe ApplicationHelper do 2 | describe "#singular_or_plural" do 3 | it 'returns "singular" when the number is 1' do 4 | expect(helper.singular_or_plural(1)).to eq("singular") 5 | end 6 | 7 | it 'returns "plural" when the number is not 1' do 8 | expect(helper.singular_or_plural(2)).to eq("plural") 9 | expect(helper.singular_or_plural(0)).to eq("plural") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/lib/google_analytics/analytics_import_service_spec.rb: -------------------------------------------------------------------------------- 1 | require "google/apis/analyticsreporting_v4" 2 | 3 | describe GoogleAnalytics::AnalyticsImportService do 4 | let(:google_client) { double("client") } 5 | before { allow(subject).to receive(:client).and_return(google_client) } 6 | 7 | describe "#page_views" do 8 | it "returns a hash containing the clicks on a service link" do 9 | google_response = GoogleAnalytics::ClicksResponseFactory.build([ 10 | { base_path: "/living-statue-permit/sandford", 11 | local_link: "https://sandford-council.gov.uk/no-for-the-greater-good", 12 | clicks: 5 }, 13 | ]) 14 | 15 | allow(google_client).to receive(:batch_get_reports).and_return(google_response) 16 | 17 | response = subject.activity 18 | expect(response).to eq([ 19 | { 20 | base_path: "/living-statue-permit/sandford", 21 | local_link: "https://sandford-council.gov.uk/no-for-the-greater-good", 22 | clicks: 5, 23 | }, 24 | ]) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/google_analytics/clicks_response_spec.rb: -------------------------------------------------------------------------------- 1 | module GoogleAnalytics 2 | describe ClicksResponse do 3 | let(:response) do 4 | GoogleAnalytics::ClicksResponseFactory.build([ 5 | { 6 | base_path: "/pay-unicycle-registration/clownsville", 7 | local_link: "https://clownsville.gov.uk/crusty-jugglers", 8 | clicks: 400, 9 | }, 10 | { 11 | base_path: "/tiny-bicycle-riding-lessons/boffo-town", 12 | local_link: "https://boffo-town-council.gov.uk/wheeled-transport", 13 | clicks: 500, 14 | }, 15 | ]) 16 | end 17 | 18 | it "returns the number of clicks for a local authority interaction" do 19 | page_views = ClicksResponse.new.parse(response) 20 | expected_response = [ 21 | { 22 | base_path: "/pay-unicycle-registration/clownsville", 23 | local_link: "https://clownsville.gov.uk/crusty-jugglers", 24 | clicks: 400, 25 | }, 26 | { 27 | base_path: "/tiny-bicycle-riding-lessons/boffo-town", 28 | local_link: "https://boffo-town-council.gov.uk/wheeled-transport", 29 | clicks: 500, 30 | }, 31 | ] 32 | 33 | expect(page_views).to eq(expected_response) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/google_analytics/client_spec.rb: -------------------------------------------------------------------------------- 1 | describe GoogleAnalytics::Client do 2 | describe "Connecting to the Google Analytics API" do 3 | let(:json_key) { { client_email: "test@test.com", private_key: "key" } } 4 | 5 | before do 6 | @google_private_key = ENV["GOOGLE_PRIVATE_KEY"] 7 | ENV["GOOGLE_PRIVATE_KEY"] = "key" 8 | @google_client_email = ENV["GOOGLE_CLIENT_EMAIL"] 9 | ENV["GOOGLE_CLIENT_EMAIL"] = "test@test.com" 10 | allow(OpenSSL::PKey::RSA).to receive(:new).and_return("key") 11 | end 12 | 13 | after do 14 | ENV["GOOGLE_PRIVATE_KEY"] = @google_private_key 15 | ENV["GOOGLE_CLIENT_EMAIL"] = @google_client_email 16 | end 17 | 18 | it "uses version 4" do 19 | expect(Google::Apis::AnalyticsreportingV4::VERSION).to eq("V4") 20 | end 21 | 22 | context "api client" do 23 | subject { GoogleAnalytics::Client.new.build } 24 | 25 | it "is an instance of AnalyticsReportingService" do 26 | expect(subject).to be_kind_of(Google::Apis::AnalyticsreportingV4::AnalyticsReportingService) 27 | end 28 | end 29 | 30 | context "when setting up authorization" do 31 | subject { GoogleAnalytics::Client.new.build } 32 | 33 | it "uses the given client email from the json key" do 34 | expect(subject.authorization.issuer).to eq("test@test.com") 35 | end 36 | 37 | it "uses the given private key the json key" do 38 | expect(subject.authorization.signing_key).to eq("key") 39 | end 40 | 41 | it "uses the given scope" do 42 | options = { scope: "https://scope.com/analytics" } 43 | auth = GoogleAnalytics::Client.new.build(**options) 44 | 45 | expect(auth.authorization.scope).to include("https://scope.com/analytics") 46 | end 47 | 48 | it "uses read only scope as default, when none is provided" do 49 | expect(subject.authorization.scope).to include("https://www.googleapis.com/auth/analytics.readonly") 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/export/fixtures/bad_links_url_status.csv: -------------------------------------------------------------------------------- 1 | ga:dimension36,ga:dimension37 2 | http://www.carmarthenshire.gov.uk/Cymraeg/addysg/childrens-services/Pages/fostering.aspx,Page not found 3 | http://www.warwickshire.gov.uk/azrecycling,Website unavailable 4 | http://www.southoxon.gov.uk/dogwardens,Page requires login 5 | https://portal.southtyneside.info/eservices/frmHomepage.aspx?FunctionId=79&ignore=0,Security Error 6 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/export/fixtures/exported_links.csv: -------------------------------------------------------------------------------- 1 | Authority Name,GSS,Description,LGSL,LGIL,URL,Title,Supported by GOV.UK 2 | Exeter,456,Service 123: Interaction 0,123,0,http://www.example.com/456/0,,true 3 | Exeter,456,Service 123: Interaction 1,123,1,http://www.example.com/456/1,,true 4 | Exeter,456,Service 666: Interaction 0,666,0,http://www.example.com/456/0,,false 5 | London,123,Service 123: Interaction 0,123,0,http://www.example.com/123/0,,true 6 | London,123,Service 123: Interaction 1,123,1,http://www.example.com/123/1,,true 7 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/export/fixtures/ni_link.csv: -------------------------------------------------------------------------------- 1 | Authority Name,GSS,Description,LGSL,LGIL,URL,Title,Supported by GOV.UK 2 | Belfast,456,Service 123: Interaction 1,123,1,http://www.example.com/456/1,,true 3 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/analytics_importer_spec.rb: -------------------------------------------------------------------------------- 1 | describe LocalLinksManager::Import::AnalyticsImporter do 2 | describe "importing GA data about local authority clicks" do 3 | let(:ga_data) do 4 | [ 5 | { 6 | base_path: "/living-statue-permit/sandford", 7 | local_link: "https://sandford-council.gov.uk/no-for-the-greater-good", 8 | clicks: 5, 9 | }, 10 | { 11 | base_path: "/living-statue-permit/royston-vasey", 12 | local_link: "https://rv-council.gov.uk/no-for-the-greater-good", 13 | clicks: 4, 14 | }, 15 | { 16 | base_path: "/trouble-at-mill", 17 | local_link: "https://something-unexpected.com", 18 | clicks: 23, 19 | }, 20 | ] 21 | end 22 | 23 | let(:service_interaction) { create :service_interaction, govuk_slug: "living-statue-permit" } 24 | let(:sandford) { create :local_authority, slug: "sandford" } 25 | let(:hogsmeade) { create :local_authority, slug: "hogsmeade" } 26 | 27 | before do 28 | @sandford_link = create :link, service_interaction:, local_authority: sandford 29 | end 30 | 31 | it "imports successfully even with non-applicable data" do 32 | response = described_class.new(ga_data).import_records 33 | 34 | expect(response).to be_successful 35 | end 36 | 37 | it "imports clicks for matching councils and govuk_slugs" do 38 | described_class.new(ga_data).import_records 39 | 40 | link_with_analytics = Link.lookup_by_base_path("/living-statue-permit/sandford") 41 | expect(link_with_analytics.analytics).to be 5 42 | end 43 | 44 | it "resets the count for links that are not in the data set" do 45 | @hogsmeade_link = create :link, service_interaction:, local_authority: hogsmeade, analytics: 25 46 | 47 | described_class.new(ga_data).import_records 48 | 49 | link_with_analytics = Link.lookup_by_base_path("/living-statue-permit/hogsmeade") 50 | expect(link_with_analytics.analytics).to be 0 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/imported_links.csv: -------------------------------------------------------------------------------- 1 | Authority Name,SNAC,GSS,Description,LGSL,LGIL,URL,Supported by GOV.UK,Status,New URL 2 | blah,blah,S1,blah,blah,blah,blah,blah,blah,blah 3 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/imported_links_all_errors.csv: -------------------------------------------------------------------------------- 1 | Authority Name,SNAC,GSS,Description,LGSL,LGIL,URL,Supported by GOV.UK,Status,New URL 2 | blah,blah,S1,blah,1,1,blah,blah,blah,blurgh 3 | blah,blah,S1,blah,2,1,blah,blah,blah,blurgh 4 | blah,blah,S1,blah,3,1,blah,blah,blah,blurgh 5 | blah,blah,S1,blah,4,1,blah,blah,blah,blurgh 6 | blah,blah,S1,blah,5,1,blah,blah,blah,blurgh 7 | blah,blah,S1,blah,6,1,blah,blah,blah,blurgh 8 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/imported_links_few_errors.csv: -------------------------------------------------------------------------------- 1 | Authority Name,SNAC,GSS,Description,LGSL,LGIL,URL,Supported by GOV.UK,Status,New URL 2 | blah,blah,S1,blah,1,1,blah,blah,blah,https://www.gov.uk 3 | blah,blah,S1,blah,2,1,blah,blah,blah,https://www.gov.uk 4 | blah,blah,S1,blah,3,1,blah,blah,blah,https://www.gov.uk 5 | blah,blah,S1,blah,4,1,blah,blah,blah,https://www.gov.uk 6 | blah,blah,S1,blah,5,1,blah,blah,blah,blurgh 7 | blah,blah,S1,blah,6,1,blah,blah,blah,blurgh 8 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/imported_links_nothing_to_import.csv: -------------------------------------------------------------------------------- 1 | Authority Name,SNAC,GSS,Description,LGSL,LGIL,URL,Supported by GOV.UK,Status,New URL 2 | blah,blah,S1,blah,99,7,blah,blah,blah,https://www.gov.uk 3 | blah,blah,S1,blah,99,8,blah,blah,blah,https://www.gov.uk -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/local-authorities.csv: -------------------------------------------------------------------------------- 1 | id,gss,snac,local_custodian_code,tier_id,parent_local_authority_id,slug,country_name,homepage_url,name 2 | 2120,S12000033,00QA,9051,3,,aberdeen-city-council,Scotland,http://www.aberdeencity.gov.uk/,"Aberdeen City Council" 3 | 2118,S12000034,00QB,9052,3,,aberdeenshire-council,Scotland,http://www.some.website,"Aberdeenshire Council" 4 | 1938,E07000223,45UB,9053,2,,adur-district-council,England,http://www.some.other.website,"Adur District Council" 5 | 1724,E10000002,11,9054,1,,buckinghamshire-county-council,England,http://buckinghamshire,"Buckinghamshire County Council" 6 | 2131,E06000053,00HF,9055,3,,scilly-council,England,http://scilly,"Isles of Scilly Council" 7 | 1977,E09000019,00AU,9056,3,,islington-council,England,http://islington,"Islington Borough Council" 8 | 1994,E08000034,00CZ,9057,3,,kirklees-council,England,https://kirklees,"Kirklees Borough Council" 9 | 12192,N09000007,N09000007,9058,3,,lisburn-and-castlereagh-council,Northern Ireland,http://lisburn,"Lisburn and Castlereagh City Council" 10 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/local_contacts_sample.csv: -------------------------------------------------------------------------------- 1 | Name,Home page URL,Contact page URL,SNAC Code,Address Line 1,Address Line 2,Town,City,County,Postcode,Telephone Number 1 Description,Telephone Number 1,Telephone Number 2 Description,Telephone Number 2,Telephone Number 3 Description,Telephone Number 3,Fax,Main Contact Email,Opening Hours 2 | Adur District Council,http://www.adur.gov.uk,http://www.adur.gov.uk/contact-us/index.htm,45UB,Civic Centre,Ham Road,Shoreham-by-Sea,,West Sussex,BN43 6PR,Main switchboard,01273 263 000,,,,,01273 263 224,info@adur.gov.uk, 3 | Allerdale Borough Council,http://www.allerdale.gov.uk,http://www.allerdale.gov.uk/contact-or-find-us.aspx,16UB,Allerdale House,,Workington,,Cumbria,CA14 3YJ,,01900 702 702,,,,,01900 702 507,enquiries@allerdale.gov.uk, 4 | Basildon District Council,http://www.basildon.gov.uk/,http://www.basildon.gov.uk/contactus,22UB,The Basildon Centre,St. Martin's Square,Basildon,,Essex,SS14 1DL,Main switchboard,01268 533 333,Out of hours emergency,01268 286 622,,,01268 294 350,mailroom@basildon.gov.uk, 5 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/sample.csv: -------------------------------------------------------------------------------- 1 | Identifier,Label,Description 2 | 1614,16 to 19 bursary fund,"They might struggle with the costs" 3 | 13,Abandoned shopping trolleys,"Abandoned shopping trolleys have a negative impact" 4 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/fixtures/sample_malformed.csv: -------------------------------------------------------------------------------- 1 | Identifier,Label,Description, 2 | 1614,16 to 19 bursary fund,"They might struggle with the costs", 3 | 13,"malformed"Abandoned shopping trolleys,"Abandoned shopping trolleys have a negative impact", 4 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/import_comparer_spec.rb: -------------------------------------------------------------------------------- 1 | describe LocalLinksManager::Import::ImportComparer do 2 | let(:destination_records) { (1..8).map { |num| RaceCompetitor.new(num) } } 3 | subject(:ImportComparer) { described_class.new } 4 | 5 | context "when records that are in the destination are missing from the source" do 6 | let(:incomplete_source_records) { (1..5).map { |num| RaceCompetitor.new(num) } } 7 | 8 | it "detects and returns them" do 9 | incomplete_source_records.each do |racer| 10 | subject.add_source_record(racer.number) 11 | end 12 | 13 | detected = subject.check_missing_records(destination_records, &:number) 14 | expect(detected).to match_array([6, 7, 8]) 15 | end 16 | end 17 | 18 | context "when all destination records are still present in the source" do 19 | let(:complete_source_records) { (1..9).map { |num| RaceCompetitor.new(num) } } 20 | 21 | it "returns an empty array" do 22 | complete_source_records.each do |racer| 23 | subject.add_source_record(racer.number) 24 | end 25 | 26 | expect(subject.check_missing_records(destination_records, &:number)).to be_empty 27 | end 28 | end 29 | end 30 | 31 | class RaceCompetitor 32 | attr_accessor :number 33 | 34 | def initialize(race_number) 35 | @number = race_number 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/lib/local-links-manager/import/missing_links_spec.rb: -------------------------------------------------------------------------------- 1 | describe LocalLinksManager::Import::MissingLinks do 2 | describe "#add_missing_links" do 3 | let!(:disabled_service_interaction) { create(:service_interaction, live: false) } 4 | let!(:first_live_service_interaction) { create(:service_interaction, live: true) } 5 | let!(:second_live_service_interaction) { create(:service_interaction, live: true) } 6 | 7 | let!(:council_with_no_links) { create(:local_authority) } 8 | let!(:council_with_a_link) { create(:local_authority) } 9 | 10 | it "adds a missing link for each live service interaction that a council does not have a link for" do 11 | described_class.new.add_missing_links 12 | 13 | expect(council_with_no_links.provided_service_links.count).to eq(2) 14 | end 15 | 16 | it "does not add a link if the local authority already has one for that service interaction" do 17 | create(:link, service_interaction: first_live_service_interaction, local_authority: council_with_a_link) 18 | 19 | described_class.new.add_missing_links 20 | 21 | expect(council_with_a_link.links.without_url.count).to eq(1) 22 | expect(council_with_a_link.links.with_url.count).to eq(1) 23 | end 24 | 25 | it "does not add links for service interactions that are not live" do 26 | described_class.new.add_missing_links 27 | 28 | expect(Link.where(service_interaction: disabled_service_interaction).count).to eq(0) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/lib/tasks/export/blank_csv_exporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Export blank csvs" do 4 | describe "export:blank_csv" do 5 | before do 6 | service = create(:service, :district_unitary, lgsl_code: 1) 7 | create(:service_interaction, service:) 8 | 9 | service = create(:service, :county_unitary, lgsl_code: 2) 10 | create(:service_interaction, service:) 11 | 12 | clean_files 13 | end 14 | 15 | after { clean_files } 16 | 17 | it "should write a blank csv for a district" do 18 | args = Rake::TaskArguments.new(%i[tier_name], %w[district]) 19 | Rake::Task["export:blank_csv"].execute(args) 20 | 21 | expect(File).to exist("blank_file_district.csv") 22 | end 23 | 24 | it "should write a blank csv for a county" do 25 | args = Rake::TaskArguments.new(%i[tier_name], %w[county]) 26 | Rake::Task["export:blank_csv"].execute(args) 27 | 28 | expect(File).to exist("blank_file_county.csv") 29 | end 30 | 31 | it "should write a blank csv for a unitary body" do 32 | args = Rake::TaskArguments.new(%i[tier_name], %w[unitary]) 33 | Rake::Task["export:blank_csv"].execute(args) 34 | 35 | expect(File).to exist("blank_file_unitary.csv") 36 | end 37 | end 38 | end 39 | 40 | def clean_files 41 | File.delete("blank_file_district.csv") if File.exist?("blank_file_district.csv") 42 | File.delete("blank_file_county.csv") if File.exist?("blank_file_county.csv") 43 | File.delete("blank_file_unitary.csv") if File.exist?("blank_file_unitary.csv") 44 | end 45 | -------------------------------------------------------------------------------- /spec/lib/tasks/export/link_exporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Export tasks" do 4 | describe "export:links:s3" do 5 | it "should write the links to S3" do 6 | service = create(:service, :all_tiers, lgsl_code: 1) 7 | service_interaction = create(:service_interaction, service:) 8 | link = create(:link, service_interaction:) 9 | 10 | la = LocalAuthority.last 11 | interaction = service_interaction.interaction 12 | expected_body = <<~EXPECTED_BODY_TEXT 13 | Authority Name,GSS,Description,LGSL,LGIL,URL,Title,Supported by GOV.UK 14 | #{la.name},#{la.gss},#{service.label}: #{interaction.label},#{service.lgsl_code},#{interaction.lgil_code},#{link.url},#{link.title},true 15 | EXPECTED_BODY_TEXT 16 | 17 | s3 = double 18 | allow(Aws::S3::Client).to receive(:new).and_return(s3) 19 | expect(s3).to receive(:put_object).with({ 20 | body: expected_body, 21 | bucket: nil, 22 | key: "data/local-links-manager/links_to_services_provided_by_local_authorities.csv", 23 | }) 24 | 25 | Rake::Task["export:links:s3"].execute 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lib/tasks/export/links_status_csv_exporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Export tasks" do 4 | describe "export:links:status" do 5 | it "should write the links to S3" do 6 | service = create(:service, :all_tiers, lgsl_code: 1) 7 | local_authority = create(:local_authority) 8 | service_interaction = create(:service_interaction, service: service) 9 | link = create(:link, service_interaction: service_interaction, local_authority: local_authority) 10 | 11 | expected_body = <<~EXPECTED_BODY_TEXT 12 | Link,Local Authority,Service,Status,Problem Summary 13 | #{link.url},#{local_authority.name},#{service.label},#{link.status},#{link.problem_summary} 14 | EXPECTED_BODY_TEXT 15 | 16 | s3 = double 17 | allow(Aws::S3::Client).to receive(:new).and_return(s3) 18 | expect(s3).to receive(:put_object).with({ 19 | body: expected_body, 20 | bucket: nil, 21 | key: "data/local-links-manager/links_with_local_authority_service.csv", 22 | }) 23 | 24 | Rake::Task["export:links:status"].execute 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/tasks/import/service_links_from_csv_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Import tasks" do 4 | describe "import:service_links" do 5 | it "imports service links from the CSV file" do 6 | la = create(:local_authority, slug: "angus") 7 | create(:service, lgsl_code: 1) 8 | create(:interaction, lgil_code: 1) 9 | args = Rake::TaskArguments.new(%i[lgsl_code lgil_code filename], [1, 1, "spec/fixtures/service-links.csv"]) 10 | 11 | expect { Rake::Task["import:service_links"].execute(args) }.to output(/\[1\] links imported/).to_stdout 12 | 13 | expect(la.links.first.url).to eq("https://www.example.com/new-service") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/lib/tasks/service_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Service tasks" do 4 | describe "service:enable" do 5 | it "should create the service" do 6 | create(:interaction, lgil_code: 1) 7 | args = Rake::TaskArguments.new(%i[lgsl lgil label slug], [1, 1, "New Service", "new-service"]) 8 | 9 | expect { Rake::Task["service:enable"].execute(args) } 10 | .to change { Service.count }.from(0).to(1) 11 | .and change { ServiceInteraction.count }.from(0).to(1) 12 | end 13 | end 14 | 15 | describe "service:destroy" do 16 | it "should destroy the service" do 17 | service = create(:service, :all_tiers, lgsl_code: 1) 18 | service_interaction = create(:service_interaction, service:) 19 | create_list(:link, 3, service_interaction:) 20 | args = Rake::TaskArguments.new(%i[lgsl_code], [1]) 21 | 22 | expect { Rake::Task["service:destroy"].execute(args) } 23 | .to change { Service.count }.from(1).to(0) 24 | .and change { ServiceInteraction.count }.from(1).to(0) 25 | .and change { Link.count }.from(3).to(0) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/models/interaction_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Interaction, type: :model do 2 | before(:each) do 3 | create(:interaction) 4 | end 5 | 6 | it { is_expected.to validate_presence_of(:lgil_code) } 7 | it { is_expected.to validate_presence_of(:label) } 8 | it { is_expected.to validate_presence_of(:slug) } 9 | it { is_expected.to validate_uniqueness_of(:lgil_code) } 10 | it { is_expected.to validate_uniqueness_of(:label) } 11 | it { is_expected.to validate_uniqueness_of(:slug) } 12 | 13 | it { is_expected.to have_many(:service_interactions) } 14 | end 15 | -------------------------------------------------------------------------------- /spec/models/service_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Service, type: :model do 2 | before(:each) do 3 | create(:service) 4 | end 5 | 6 | it { is_expected.to validate_presence_of(:lgsl_code) } 7 | it { is_expected.to validate_presence_of(:label) } 8 | it { is_expected.to validate_presence_of(:slug) } 9 | it { is_expected.to validate_uniqueness_of(:lgsl_code) } 10 | it { is_expected.to validate_uniqueness_of(:label) } 11 | it { is_expected.to validate_uniqueness_of(:slug) } 12 | 13 | it { is_expected.to have_many(:service_interactions) } 14 | 15 | describe "#tiers" do 16 | subject { create(:service, :all_tiers) } 17 | let(:result) { subject.tiers } 18 | 19 | it "returns an array of tier names" do 20 | expect(result).to match_array(%w[unitary district county]) 21 | end 22 | end 23 | 24 | describe "#update_broken_link_count" do 25 | it "updates the broken_link_count" do 26 | link = create(:link, status: "broken") 27 | service = link.service 28 | expect { service.update_broken_link_count } 29 | .to change { service.broken_link_count } 30 | .from(0).to(1) 31 | end 32 | 33 | it "ignores unchecked links" do 34 | service = create(:service, broken_link_count: 1) 35 | create(:link, service:, status: nil) 36 | expect { service.update_broken_link_count } 37 | .to change { service.broken_link_count } 38 | .from(1).to(0) 39 | end 40 | end 41 | 42 | describe "#delete_all_tiers" do 43 | it "deletes all tiers associated with a service" do 44 | service = create(:service, :all_tiers) 45 | service.delete_all_tiers 46 | expect(service.tiers).to be_empty 47 | end 48 | end 49 | 50 | describe "#required_tiers" do 51 | it "returns the correct tiers" do 52 | service = create(:service) 53 | expect(service.required_tiers("all")).to eq([2, 3, 1]) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | require "gds-sso/lint/user_spec" 2 | 3 | RSpec.describe User, type: :model do 4 | it_behaves_like "a gds-sso user class" 5 | end 6 | -------------------------------------------------------------------------------- /spec/presenters/link_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/url_status_presentation" 2 | 3 | describe LinkPresenter do 4 | it_behaves_like "a UrlStatusPresentation module" 5 | end 6 | -------------------------------------------------------------------------------- /spec/presenters/local_authority_external_content_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | describe LocalAuthorityExternalContentPresenter do 2 | describe "#present_for_publishing_api" do 3 | let(:authority) { build(:county_council, name: "Angus County Council") } 4 | let(:presenter) { described_class.new(authority) } 5 | let(:expected_response) do 6 | { 7 | description: "Website of Angus County Council", 8 | details: { 9 | url: "http://www.angus.gov.uk", 10 | }, 11 | document_type: "external_content", 12 | publishing_app: "local-links-manager", 13 | schema_name: "external_content", 14 | title: "Angus County Council", 15 | update_type: "minor", 16 | } 17 | end 18 | 19 | it "returns a hash appropriate for an external content item in the Publishing API" do 20 | expect(presenter.present_for_publishing_api).to eq(expected_response) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/presenters/local_authority_hash_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | describe LocalAuthorityApiResponsePresenter do 2 | describe "#to_h" do 3 | context "when the authority has a SNAC" do 4 | let(:authority) { build(:district_council) } 5 | let(:presenter) { described_class.new(authority) } 6 | let(:expected_response) do 7 | { 8 | "local_authorities" => [ 9 | { 10 | "name" => authority.name, 11 | "homepage_url" => authority.homepage_url, 12 | "country_name" => authority.country_name, 13 | "tier" => "district", 14 | "slug" => authority.slug, 15 | "snac" => authority.snac, 16 | "gss" => authority.gss, 17 | }, 18 | ], 19 | } 20 | end 21 | 22 | it "returns a json with both GSS and SNAC codes" do 23 | expect(presenter.present).to eq(expected_response) 24 | end 25 | end 26 | 27 | context "when the authority does not have a SNAC" do 28 | let(:authority) { build(:district_council, snac: nil) } 29 | let(:presenter) { described_class.new(authority) } 30 | let(:expected_response) do 31 | { 32 | "local_authorities" => [ 33 | { 34 | "name" => authority.name, 35 | "homepage_url" => authority.homepage_url, 36 | "country_name" => authority.country_name, 37 | "tier" => "district", 38 | "slug" => authority.slug, 39 | "gss" => authority.gss, 40 | }, 41 | ], 42 | } 43 | end 44 | 45 | it "returns a json with GSS but no SNAC code" do 46 | expect(presenter.present).to eq(expected_response) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/presenters/service_link_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | describe ServiceLinkPresenter do 2 | let(:link) { double(:link, local_authority: double(id: 1), service: double(id: 2), interaction: double(id: 3), url: "url") } 3 | let(:view_context) { double(:view_context, edit_link_path: "path") } 4 | let(:first) { double(:first) } 5 | let(:presenter) { ServiceLinkPresenter.new(link, view_context:, first:) } 6 | 7 | describe "#initialize" do 8 | it "initializes with correct attributes" do 9 | expect(presenter.view_context).to eq(view_context) 10 | expect(presenter.first).to eq(first) 11 | end 12 | end 13 | 14 | describe "#row_data" do 15 | it "returns correct data" do 16 | expected_data = { 17 | local_authority_id: 1, 18 | service_id: 2, 19 | interaction_id: 3, 20 | url: "url", 21 | } 22 | 23 | expect(presenter.row_data).to eq(expected_data) 24 | end 25 | end 26 | 27 | describe "#edit_path" do 28 | it "returns correct path" do 29 | expect(presenter.edit_path).to eq("path") 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/presenters/url_status_presentation_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe UrlStatusPresentation do 4 | let(:dummy_class) do 5 | Class.new do 6 | include UrlStatusPresentation 7 | 8 | attr_accessor :status, :problem_summary, :link_errors, :link_warnings, :link_last_checked, :url, :interaction 9 | 10 | def initialize 11 | @link_errors = [] 12 | @link_warnings = [] 13 | @interaction = OpenStruct.new(lgil_code: "123") 14 | end 15 | end 16 | end 17 | 18 | subject { dummy_class.new } 19 | 20 | describe "#status_description" do 21 | context "when status is nil" do 22 | it 'returns "Not checked"' do 23 | expect(subject.status_description).to eq "Not checked" 24 | end 25 | end 26 | 27 | context 'when status is "pending"' do 28 | before { subject.status = "pending" } 29 | 30 | it 'returns "Pending"' do 31 | expect(subject.status_description).to eq "Pending" 32 | end 33 | end 34 | 35 | context "when status 'some_other_status'" do 36 | before { subject.status = "some_other_status" } 37 | 38 | it "returns the problem_summary" do 39 | expect(subject.status_description).to eq nil 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/requests/bad_homepage_csv_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Bad homepage CSV" do 2 | it_behaves_like "it is forbidden to non-GDS Editors", "/bad_homepage_url_status.csv" 3 | end 4 | -------------------------------------------------------------------------------- /spec/requests/broken_links_page_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Broken links page" do 2 | it_behaves_like "redirects non-GDS Editors to services page", "/" 3 | end 4 | 5 | describe "Broken links page" do 6 | before do 7 | login_as_gds_editor 8 | @local_authority = create(:local_authority, name: "North Midlands") 9 | @service = create(:service, label: "Aardvark Wardens") 10 | @interaction = create(:interaction, label: "Reporting") 11 | @service_interaction = create(:service_interaction, service: @service, interaction: @interaction) 12 | end 13 | 14 | context "GET edit" do 15 | it "GET edit handles URL passed in via flash" do 16 | get "/local_authorities/north-midlands/services/aardvark-wardens/reporting/edit" 17 | expect(response).to have_http_status(:ok) 18 | 19 | flash_hash = ActionDispatch::Flash::FlashHash.new 20 | flash_hash[:link_url] = "https://www.example.com" 21 | session["flash"] = flash_hash.to_session_value 22 | 23 | get "/local_authorities/north-midlands/services/aardvark-wardens/reporting/edit" 24 | expect(response).to have_http_status(:ok) 25 | end 26 | end 27 | 28 | context "GET homepage_links_status_csv" do 29 | it "returns a 200 response" do 30 | get "/check_homepage_links_status.csv" 31 | expect(response).to have_http_status(:ok) 32 | expect(response.headers["Content-Type"]).to eq("text/csv") 33 | end 34 | end 35 | 36 | context "GET links_status_csv" do 37 | it "returns a 200 response" do 38 | get "/check_links_status.csv" 39 | expect(response).to have_http_status(:ok) 40 | expect(response.headers["Content-Type"]).to eq("text/csv") 41 | end 42 | end 43 | 44 | context "GET bad_links_url_and_status_csv" do 45 | it "returns a 200 response" do 46 | get "/bad_links_url_status.csv" 47 | expect(response).to have_http_status(:ok) 48 | expect(response.headers["Content-Type"]).to eq("text/csv") 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/requests/link_status_csv_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Link Status CSV" do 2 | it_behaves_like "it is forbidden to non-GDS Editors", "/check_homepage_links_status.csv" 3 | it_behaves_like "it is forbidden to non-GDS Editors", "/check_links_status.csv" 4 | it_behaves_like "it is forbidden to non-GDS Editors", "/bad_links_url_status.csv" 5 | end 6 | -------------------------------------------------------------------------------- /spec/services/local_authority_external_content_publisher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe LocalAuthorityExternalContentPublisher do 2 | before do 3 | WebMock.reset! 4 | @authority = build(:local_authority, content_id: SecureRandom.uuid) 5 | stub_publishing_api_subject_published(@authority) 6 | stub_publishing_api_for_subject(@authority) 7 | @authority.save! 8 | WebMock.reset! 9 | end 10 | 11 | describe "#publish" do 12 | before do 13 | @stubs = stub_publishing_api_for_subject(@authority) 14 | end 15 | 16 | context "with a published local authority link" do 17 | it "calls the publishing api to update and publish" do 18 | stub_publishing_api_subject_published(@authority) 19 | described_class.new(@authority).publish 20 | expect(@stubs.first).to have_been_requested.once 21 | expect(@stubs.last).to have_been_requested 22 | end 23 | end 24 | end 25 | 26 | describe "#unpublish" do 27 | before do 28 | @stub = stub_unpublish_for_subject(@authority) 29 | end 30 | 31 | context "with a published local authority link" do 32 | it "calls the publishing api to unpublish" do 33 | stub_publishing_api_subject_published(@authority) 34 | described_class.new(@authority).unpublish 35 | expect(@stub).to have_been_requested.once 36 | end 37 | end 38 | 39 | context "with a non-published local authority link" do 40 | it "does nothing" do 41 | stub_publishing_api_subject_unpublished(@authority) 42 | described_class.new(@authority).unpublish 43 | expect(@stub).not_to have_been_requested 44 | end 45 | end 46 | 47 | context "with a missing local authority link" do 48 | it "does nothing" do 49 | stub_publishing_api_subject_missing(@authority) 50 | described_class.new(@authority).unpublish 51 | expect(@stub).not_to have_been_requested 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/support/appear_before_matcher.rb: -------------------------------------------------------------------------------- 1 | module SortOrderMatchers 2 | class AppearBeforeMatcher < Capybara::RSpecMatchers::Matchers::Base 3 | def matches?(earlier_content) 4 | @earlier_content = earlier_content 5 | page.body.index(earlier_content) < page.body.index(later_content) 6 | end 7 | 8 | def description 9 | "appear in the rendered page before #{later_content}" 10 | end 11 | 12 | def failure_message 13 | "Expected to find #{@earlier_content} in the page before #{later_content} but it isn't" 14 | end 15 | 16 | def page 17 | Capybara.current_session 18 | end 19 | 20 | private 21 | 22 | def later_content 23 | @args.first 24 | end 25 | end 26 | 27 | def appear_before(later_content) 28 | AppearBeforeMatcher.new(later_content) 29 | end 30 | end 31 | 32 | RSpec.configure do |config| 33 | config.include SortOrderMatchers, type: :feature 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.include FactoryBot::Syntax::Methods 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/gds_api_adapters.rb: -------------------------------------------------------------------------------- 1 | require "gds_api/test_helpers/publishing_api" 2 | 3 | module LocalAuthoritiesExternalContentHelpers 4 | include GdsApi::TestHelpers::PublishingApi 5 | 6 | def stub_publishing_api_for_external_content 7 | stub_any_publishing_api_put_content 8 | stub_any_publishing_api_unpublish 9 | stub_any_publishing_api_publish 10 | stub_request(:get, %r{\A#{GdsApi::TestHelpers::PublishingApi::PUBLISHING_API_V2_ENDPOINT}}) 11 | .to_return(status: 404, headers: { "Content-Type" => "application/json; charset=utf-8" }) 12 | end 13 | 14 | def stub_publishing_api_for_subject(local_authority, body_merge: {}) 15 | body = LocalAuthorityExternalContentPresenter.new(local_authority).present_for_publishing_api 16 | stub_publishing_api_put_content_links_and_publish(body.merge(body_merge), local_authority.content_id, { update_type: nil }) 17 | end 18 | 19 | def stub_unpublish_for_subject(local_authority) 20 | stub_publishing_api_unpublish(local_authority.content_id, { body: { type: "gone" } }) 21 | end 22 | 23 | def stub_publishing_api_subject_published(local_authority) 24 | stub_publishing_api_has_item({ 25 | content_id: local_authority.content_id, 26 | publication_state: "published", 27 | }) 28 | end 29 | 30 | def stub_publishing_api_subject_unpublished(local_authority) 31 | stub_publishing_api_has_item({ 32 | content_id: local_authority.content_id, 33 | publication_state: "unpublished", 34 | }) 35 | end 36 | 37 | def stub_publishing_api_subject_missing(local_authority) 38 | stub_publishing_api_does_not_have_item(local_authority.content_id) 39 | end 40 | end 41 | RSpec.configuration.include LocalAuthoritiesExternalContentHelpers 42 | -------------------------------------------------------------------------------- /spec/support/shoulda.rb: -------------------------------------------------------------------------------- 1 | Shoulda::Matchers.configure do |config| 2 | config.integrate do |with| 3 | with.test_framework :rspec 4 | with.library :rails 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/stub_csv_rows.rb: -------------------------------------------------------------------------------- 1 | module StubCSVRows 2 | def stub_csv_rows(rows) 3 | receive_each_row_and_yield = rows 4 | .to_enum 5 | .each 6 | .with_object(receive(:each_row)) do |row, matcher| 7 | matcher.and_yield(row) 8 | end 9 | allow(csv_downloader).to receive_each_row_and_yield 10 | end 11 | end 12 | 13 | RSpec.configuration.include StubCSVRows, :csv_importer 14 | -------------------------------------------------------------------------------- /spec/support/timecop.rb: -------------------------------------------------------------------------------- 1 | require "timecop" 2 | 3 | RSpec.configure do |config| 4 | config.after :each do 5 | Timecop.return 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/webmock.rb: -------------------------------------------------------------------------------- 1 | require "webmock/rspec" 2 | 3 | WebMock.disable_net_connect!(allow_localhost: true) 4 | -------------------------------------------------------------------------------- /spec/system/edit_link_page_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Edit link page" do 2 | let(:owning_department) { "department-of-aardvarks" } 3 | let!(:service) { create(:service, label: "Aardvark Wardens", organisation_slugs: [owning_department]) } 4 | let!(:interaction) { create(:interaction, label: "reporting") } 5 | let!(:service_interaction) { create(:service_interaction, service:, interaction:) } 6 | let!(:local_authority) { create(:district_council, slug: "north-midlands") } 7 | let!(:link) { create(:link, local_authority:, service_interaction:) } 8 | 9 | context "as an owning user" do 10 | before { login_as_department_user(organisation_slug: owning_department) } 11 | 12 | it "doesn't show the delete link" do 13 | visit "/local_authorities/north-midlands/services/aardvark-wardens/reporting/edit" 14 | 15 | expect(page).not_to have_button("Delete") 16 | end 17 | end 18 | 19 | context "as a GDS Editor" do 20 | before { login_as_gds_editor } 21 | 22 | it "shows the delete link" do 23 | visit "/local_authorities/north-midlands/services/aardvark-wardens/reporting/edit" 24 | 25 | expect(page).to have_button("Delete") 26 | click_on "Delete" 27 | expect(page).to have_content("Link has been deleted") 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/system/main_menu_spec.rb: -------------------------------------------------------------------------------- 1 | require "gds_api/test_helpers/organisations" 2 | 3 | RSpec.describe "Main menu" do 4 | include GdsApi::TestHelpers::Organisations 5 | 6 | context "as a normal user" do 7 | before do 8 | login_as_department_user 9 | stub_organisations_api_has_organisations_with_bodies([{ "title" => "Department of Randomness", "details" => { "slug" => "random-department" } }]) 10 | end 11 | 12 | it "shows only the Services/Switch app menu items" do 13 | visit "/" 14 | 15 | within(".govuk-header__container") do 16 | expect(page).not_to have_link("Broken Links") 17 | expect(page).not_to have_link("Councils") 18 | expect(page).to have_link("Services") 19 | expect(page).to have_link("Switch app") 20 | end 21 | end 22 | end 23 | 24 | context "as a GDS Editor" do 25 | before { login_as_gds_editor } 26 | 27 | it "shows all four menu options" do 28 | visit "/" 29 | 30 | within(".govuk-header__container") do 31 | expect(page).to have_link("Broken Links") 32 | expect(page).to have_link("Councils") 33 | expect(page).to have_link("Services") 34 | expect(page).to have_link("Switch app") 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/system/service_page_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Service page" do 2 | before do 3 | create(:service, label: "Aardvark Wardens", organisation_slugs: %w[department-of-aardvarks]) 4 | end 5 | 6 | describe "Visiting the page" do 7 | context "as an owning department user" do 8 | before { login_as_department_user(organisation_slug: "department-of-aardvarks") } 9 | 10 | it "does not show the Update Owner link" do 11 | visit "/services/aardvark-wardens" 12 | 13 | expect(page).not_to have_content("Update Owner") 14 | end 15 | end 16 | 17 | context "as a GDS Editor" do 18 | before { login_as_gds_editor } 19 | 20 | it "shows the Update Owner link" do 21 | visit "/services/aardvark-wardens" 22 | 23 | expect(page).to have_content("Update Owner") 24 | end 25 | end 26 | end 27 | 28 | describe "Updating the owner" do 29 | before { login_as_gds_editor } 30 | 31 | it "allows us to update the owner" do 32 | visit "/services/aardvark-wardens" 33 | 34 | expect(page).to have_content("department-of-aardvarks") 35 | expect(page).not_to have_content("government-digital-service") 36 | 37 | click_on "Update Owner" 38 | fill_in "Organisation Slug", with: "government-digital-service" 39 | click_on "Submit" 40 | 41 | expect(page).not_to have_content("department-of-aardvarks") 42 | expect(page).to have_content("government-digital-service") 43 | end 44 | 45 | it "allows us to cancel updating the owner" do 46 | visit "/services/aardvark-wardens" 47 | 48 | expect(page).to have_content("department-of-aardvarks") 49 | expect(page).not_to have_content("government-digital-service") 50 | 51 | click_on "Update Owner" 52 | fill_in "Organisation Slug", with: "government-digital-service" 53 | click_on "Cancel" 54 | 55 | expect(page).to have_content("department-of-aardvarks") 56 | expect(page).not_to have_content("government-digital-service") 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/tasks/missing_links_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | require "rake" 3 | 4 | RSpec.describe "import:missing_links" do 5 | before do 6 | allow(LocalLinksManager::Import::MissingLinks).to receive(:add) 7 | end 8 | 9 | it "calls LocalLinksManager::Import::MissingLinks.add" do 10 | Rake::Task["import:missing_links"].invoke 11 | expect(LocalLinksManager::Import::MissingLinks).to have_received(:add) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/validators/non_blank_url_validator_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe NonBlankUrlValidator do 4 | let(:validatable_class) do 5 | Class.new do 6 | include ActiveModel::Validations 7 | attr_accessor :url 8 | 9 | validates :url, non_blank_url: true 10 | end 11 | end 12 | 13 | subject { validatable_class.new } 14 | 15 | context "when url is valid" do 16 | it "is valid" do 17 | subject.url = "http://example.com" 18 | expect(subject).to be_valid 19 | end 20 | end 21 | 22 | context "when url is invalid" do 23 | it "is not valid" do 24 | subject.url = "invalid_url" 25 | expect(subject).not_to be_valid 26 | expect(subject.errors[:url]).to include("(invalid_url) is not a URL") 27 | end 28 | end 29 | 30 | context "when url is blank" do 31 | it "is valid" do 32 | subject.url = "" 33 | expect(subject).to be_valid 34 | end 35 | end 36 | 37 | context "when url causes an Addressable::URI::InvalidURIError" do 38 | it "is not valid" do 39 | subject.url = "http://" 40 | expect(subject).not_to be_valid 41 | expect(subject.errors[:url]).to include("(http://) is not a URL") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/local-links-manager/fa759317b4245f819140a323428fb8a121e95f02/vendor/assets/stylesheets/.keep --------------------------------------------------------------------------------