Skip to content

AO3-6726 Separate languages table into work languages and locale languages - #5923

Open
pmonfort wants to merge 9 commits into
otwcode:masterfrom
pmonfort:AO3-6726
Open

AO3-6726 Separate languages table into work languages and locale languages#5923
pmonfort wants to merge 9 commits into
otwcode:masterfrom
pmonfort:AO3-6726

Conversation

@pmonfort

@pmonfort pmonfort commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Pull Request Checklist

Issue

https://otwarchive.atlassian.net/browse/AO3-6726

Purpose

Separates the languages table, currently used for both works and admin/i18n features, into two independent tables:

  • languages — used exclusively for works
  • locale_languages — used for admin/i18n: locales, admin posts, admin post tags, abuse reports, and support/feedback forms

What this PR does:

  1. Creates the locale_languages table by cloning the existing languages tablATE TABLE ... LIKE+INSERT INTO ... SELECT), preserving IDs so existing language_id` foreign keys work without data migration.
  2. Updates associations on Locale, AdminPost, and AdminPostTag to belongsign_key: :language_id, reusing the existing FK column to avoid data migrations on those tables (per redsummernight's suggestion on related PR. Also removes an orphaned belongs_to :language from ArchiveFaq (no FK existed in the table).
  3. Updates views for abuse reports, support/feedback forms, admin posts, and locanguageinstead ofLanguage. The abuse report and feedback controllers now query LocaleLanguage` for available support languages (the models store language as a str
  4. Adds admin management for locale languages (index, new, create, edit, update)ndit policy (role-based permitted attributes), views, routes, and i18n keys. Adds a "Languages" dropdown to the admin nav with links to "Work Languages" and "Locale Lan
  5. Removes "Support available" and "Abuse support available" checkboxes from thee those fields now belong to locale languages. The columns remain in the languages table but are no longer exposed through the UI or policy.
  6. Restricts work language creation/editing to support and superadmin roles available to translation). The work languages index remains public (no authorization on index).
  7. Adds a fallback rake task (rake After:populate_locale_languages_table) to rfrom languages if needed after deploy. The migration already copies the data; this task is only needed if the migration data needs to be re-synced.

LanguagePolicy changes:

  • create? and update? restricted to support and superadmin (previously also and policy_and_abuse)
  • Permitted attributes limited to name, short, sortable_name (no more support/

LocaleLanguagePolicy roles:

  • index?: translation, support, superadmin, policy_and_abuse
  • create?: translation, superadmin
  • update?: translation, superadmin, support, policy_and_abuse (with role-ses: support can only edit support_available, policy_and_abuse can only edit abuse_support_available)

Testing Instructions

Testing instructions are in the Jira issue.

References

Credit

Cesium-Ice
Pablo Monfort (he/him)

@github-actions github-actions Bot added Has Migrations Contains migrations and therefore needs special attention when deploying Awaiting Review labels Jul 8, 2026
@pmonfort
pmonfort force-pushed the AO3-6726 branch 3 times, most recently from b692618 to 2bb1649 Compare July 8, 2026 03:23

@sarken sarken left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly nitpicks! I realize some of them come from copying the language code, and I don't expect you to go back and change anything in the existing language code (this pull request is big enough!), but it doesn't hurt to make a few tweaks on the locale language side of things.

Comment thread app/policies/language_policy.rb Outdated
Comment on lines +2 to +3
LANGUAGE_EDIT_ACCESS = %w[superadmin support].freeze
LANGUAGE_CREATE_ACCESS = %w[superadmin support].freeze

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this policy since we no longer need to split access for create and edit, much less by field. (If we ever need to split it again, we can just dig this old code out of the file's history.)

Comment thread app/policies/locale_language_policy.rb Outdated
Comment on lines +2 to +3
LANGUAGE_EDIT_ACCESS = %w[superadmin translation support policy_and_abuse].freeze
LANGUAGE_CREATE_ACCESS = %w[superadmin translation].freeze

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our naming conventions in policies are all over the place, but I think as long as we're here, switching to EDIT_ROLES and CREATE_ROLES would be shorter and consistent with the names in some of our other policies.

@@ -0,0 +1,3 @@
<h2 class="heading"><%= t(".heading") %></h2>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For both this file and /app/views/locale_languages/new.html.erb, could you add the standard HTML comments to this file and change the key to page_heading to better match the majority of our pages, please? Just like:

<!--Descriptive page name, messages and instructions-->
<h2 class="heading"><%= t(".page_heading") %></h2>
<!--/descriptions-->

<!--subnav-->
<!--/subnav-->

<!--main content-->
<%= render "form" %>
<!--/content-->

<!--/descriptions-->

<!--subnav-->
<p class="navigation actions" role="navigation">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the ARIA role from this?

authorize LocaleLanguage
@locale_language = LocaleLanguage.new(permitted_attributes(LocaleLanguage))
if @locale_language.save
flash[:notice] = t("locale_languages.successfully_added")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we usually prefer lazy lookup for the flash messages in our controllers unless we're reusing the same message in multiple actions. With that change, the key for the success messages can probably just be .success.

Comment on lines +24 to +47
(Admin::VALID_ROLES - %w[superadmin translation support policy_and_abuse]).each do |role|
context "when logged in as an admin with #{role} role" do
let(:admin) { create(:admin, roles: [role]) }

it "redirects with error" do
fake_login_admin(admin)
get :index

it_redirects_to_with_error(root_url, "Sorry, only an authorized admin can access the page you were trying to reach.")
end
end
end

%w[translation superadmin support policy_and_abuse].each do |role|
context "when logged in as an admin with #{role} role" do
let(:admin) { create(:admin, roles: [role]) }

it "renders the index template" do
fake_login_admin(admin)
get :index
expect(response).to render_template("index")
end
end
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the above, the "an action only authorized admins can access" shared example might be helpful here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, except for update. The support and policy_and_abuse roles raise UnpermittedParameters there, so the shared example's success check doesn't work. Left that one as is.

end
end

context "when the locale language does not exist" do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be good to note here and in similar places and the admin is authorized, since I assume the behavior is different for non-admins and unauthorized admins.

Comment thread lib/tasks/after_tasks.rake Outdated
$stdout.flush
end

# Fallback for the migration that copies the languages table.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be better in language_tasks.rake. We usually use after_tasks.rake for things that need to be run after deploy and never again, but if I understand correctly, this seems more like an optional thing to use in the future. (tbh, I don't think we'll need it, since the goal of this separation is to allow the tables to diverge over time. But it's probably not a bad idea to have it just in case something unexpected happens.)

We could also use some tests for this and some output that tells us if a locale language couldn't be updated or created.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it to language_tasks.rake and made it a proper re-sync. It now updates existing rows too, reports created/updated/failed counts, and prints each row that couldn't be saved. Added tests 👍

german = Language.find_or_create_by(short: "DE", name: "Deutsch", sortable_name: "Deutsch", support_available: true, abuse_support_available: true)
Locale.create(iso: "de", name: "Deutsch", language: german)
Language.find_or_create_by(short: "DE", name: "Deutsch", sortable_name: "Deutsch")
LocaleLanguage.default

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super nitpicky, but a blank line between 59 and 60 might help with readability, since we're switching from Language to LocaleLanguage. (Same with lines 73 and 74 for the Persian step.)

locale_language = LocaleLanguage.find_or_create_by(short: "en", name: "English")
Locale.find_by(iso: "en") || Locale.create(iso: "en", name: "English (US)", language_id: locale_language.id, main: 1)
else
Locale.find_by(iso: "en") || Locale.create(iso: "en", name: "English (US)", language_id: language.id, main: 1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering why the switch from set_base_locale here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reasons. set_base_locale's first line is LocaleLanguage.find_by(short: ArchiveConfig.DEFAULT_LANGUAGE_SHORT), but this rescue branch is the "ArchiveConfig didn't work" path, so calling it here would just fail again. It also runs during rake db:migrate itself, so on a database that hasn't run this PR's CreateLocaleLanguages migration yet , locale_languages doesn't exist and set_base_locale's LocaleLanguage lookup would crash the migration run. The table_exists? fork keeps the old language_id behavior for that window and switches to the locale language afterwards.

@pmonfort

pmonfort commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for your feedback! I updated the code according to the comments, let me know if it looks good to you.

@pmonfort
pmonfort requested a review from sarken September 4, 2026 02:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Coder Has Actioned Review Has Migrations Contains migrations and therefore needs special attention when deploying

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants