Initial commit: Tonalmine, a Material Design 3 theme for Redmine 7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
redmine:
|
||||
image: redmine:7.0.1
|
||||
ports:
|
||||
- "3070:3000"
|
||||
volumes:
|
||||
- redmine-sqlite:/usr/src/redmine/sqlite
|
||||
- redmine-files:/usr/src/redmine/files
|
||||
- ../tonalmine:/usr/src/redmine/themes/tonalmine
|
||||
- .:/seed:ro
|
||||
|
||||
volumes:
|
||||
redmine-sqlite:
|
||||
redmine-files:
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Demo data for visual theme testing.
|
||||
# Run: docker compose exec redmine bin/rails runner /seed/seed.rb
|
||||
# Idempotent: exits if the demo project already exists. Dates are relative to today
|
||||
# so Gantt, calendar and roadmap always show current data.
|
||||
|
||||
if Project.exists?(identifier: 'apollo')
|
||||
puts 'Demo data already present.'
|
||||
load File.join(__dir__, 'seed_extras.rb')
|
||||
exit
|
||||
end
|
||||
|
||||
srand(42)
|
||||
today = Date.today
|
||||
|
||||
# One transaction: a failure leaves no partial data.
|
||||
ActiveRecord::Base.transaction do
|
||||
Redmine::DefaultData::Loader.load('en') if Redmine::DefaultData::Loader.no_data?
|
||||
|
||||
Setting.ui_theme = 'tonalmine'
|
||||
Setting.text_formatting = 'common_mark'
|
||||
Setting.notified_events = [] # demo data must not send mail
|
||||
|
||||
admin = User.find_by(login: 'admin')
|
||||
admin.update!(must_change_passwd: false)
|
||||
User.current = admin
|
||||
|
||||
people = [
|
||||
%w[jdoe Jane Doe], %w[msmith Max Smith], %w[lnguyen Linh Nguyen], %w[pweber Paula Weber]
|
||||
].map do |login, first, last|
|
||||
User.create!(login: login, firstname: first, lastname: last, mail: "#{login}@example.com",
|
||||
password: 'password123', password_confirmation: 'password123', language: 'en')
|
||||
end
|
||||
team = Group.create!(lastname: 'Developers')
|
||||
team.users << people[0..2]
|
||||
|
||||
manager = Role.find_by!(name: 'Manager')
|
||||
developer = Role.find_by!(name: 'Developer')
|
||||
reporter = Role.find_by!(name: 'Reporter')
|
||||
|
||||
customer_field = IssueCustomField.create!(name: 'Customer', field_format: 'list', possible_values: %w[Acme Globex Initech],
|
||||
is_for_all: true, is_filter: true, tracker_ids: Tracker.ids)
|
||||
review_field = IssueCustomField.create!(name: 'Review date', field_format: 'date', is_for_all: true,
|
||||
is_filter: true, tracker_ids: Tracker.ids)
|
||||
|
||||
modules = Redmine::AccessControl.available_project_modules.map(&:to_s)
|
||||
make_project = lambda do |name, identifier, parent, description|
|
||||
Project.create!(name: name, identifier: identifier, description: description, is_public: true,
|
||||
parent: parent, tracker_ids: Tracker.ids, enabled_module_names: modules)
|
||||
end
|
||||
|
||||
apollo = make_project.('Apollo', 'apollo', nil, "Customer portal relaunch.\n\n**Goal:** ship v2.0 this quarter.")
|
||||
web = make_project.('Apollo Web', 'apollo-web', apollo, 'Frontend of the customer portal.')
|
||||
api = make_project.('Apollo API', 'apollo-api', apollo, 'REST backend.')
|
||||
infra = make_project.('Infrastructure', 'infra', nil, 'Servers, CI and monitoring.')
|
||||
projects = [apollo, web, api, infra]
|
||||
|
||||
projects.each do |p|
|
||||
Member.create!(project: p, principal: admin, role_ids: [manager.id])
|
||||
Member.create!(project: p, principal: people[0], role_ids: [manager.id])
|
||||
Member.create!(project: p, principal: team, role_ids: [developer.id])
|
||||
Member.create!(project: p, principal: people[3], role_ids: [reporter.id])
|
||||
%w[Frontend Backend Docs].each { |c| IssueCategory.create!(project: p, name: c) }
|
||||
end
|
||||
|
||||
v1 = Version.create!(project: apollo, name: '1.0', effective_date: today - 60, sharing: 'descendants')
|
||||
v2 = Version.create!(project: apollo, name: '2.0', effective_date: today + 14, sharing: 'descendants',
|
||||
description: 'Portal relaunch')
|
||||
v3 = Version.create!(project: apollo, name: '2.1', effective_date: today + 60, sharing: 'descendants')
|
||||
vi = Version.create!(project: infra, name: 'Q4 hardening', effective_date: today + 30)
|
||||
|
||||
titles = [
|
||||
'Login page shows wrong error message', 'Add CSV export to reports', 'Upgrade database driver',
|
||||
'Dashboard loads slowly with many widgets', 'Password reset mail not localized', 'Add dark mode',
|
||||
'Refactor session handling', 'Document REST authentication', 'Search ignores umlauts',
|
||||
'Broken layout on small screens', 'Add audit log', 'Rate limit API endpoints', 'Improve onboarding tour',
|
||||
'Fix timezone in due dates', 'Add SSO via OIDC', 'Remove legacy XML endpoints', 'Paginate project list',
|
||||
'Cache avatar images', 'Nightly backup verification', 'Migrate CI to new runners'
|
||||
]
|
||||
open_statuses = IssueStatus.sorted.reject(&:is_closed?)
|
||||
closed_status = IssueStatus.sorted.find(&:is_closed?)
|
||||
priorities = IssuePriority.active.to_a
|
||||
assignees = people + [admin, nil]
|
||||
|
||||
issues = Array.new(60) do |i|
|
||||
project = projects[i % projects.size]
|
||||
version = project == infra ? vi : [v1, v2, v2, v3, nil][i % 5]
|
||||
status = version == v1 || i % 9 == 7 ? closed_status : open_statuses[i % open_statuses.size]
|
||||
start = today + rand(-40..20)
|
||||
suffix = i >= titles.size ? " (#{i / titles.size + 1})" : ''
|
||||
issue = Issue.new(project: project, tracker: project.trackers.to_a[i % project.trackers.size],
|
||||
author: people[i % people.size], subject: titles[i % titles.size] + suffix,
|
||||
description: "Steps to reproduce:\n\n1. Open the page\n2. Click **Save**\n3. See the error\n\n```ruby\nputs 'hello'\n```",
|
||||
priority: priorities[i % priorities.size], assigned_to: assignees[i % assignees.size],
|
||||
category: project.issue_categories.to_a[i % 3], fixed_version: version,
|
||||
start_date: start, due_date: start + rand(3..30), estimated_hours: [2, 4, 8, 16, nil][i % 5],
|
||||
done_ratio: status.is_closed? ? 100 : [0, 10, 30, 50, 80][i % 5], is_private: i == 5)
|
||||
issue.status = status
|
||||
issue.custom_field_values = { customer_field.id => %w[Acme Globex Initech][i % 3], review_field.id => (today + i).to_s }
|
||||
issue.save!
|
||||
issue
|
||||
end
|
||||
|
||||
v1.update!(status: 'closed') # closed versions reject new issues, so close it afterwards
|
||||
|
||||
# Subtasks and relations (same project: cross-project relations are off by default)
|
||||
showcase = issues[12] # open apollo issue: gets subtasks, attachments and watchers
|
||||
[issues[24], issues[28]].each { |child| child.reload.update!(parent_issue_id: showcase.id) }
|
||||
IssueRelation.create!(issue_from: showcase, issue_to: issues[32], relation_type: 'relates')
|
||||
IssueRelation.create!(issue_from: issues[9], issue_to: issues[13], relation_type: 'blocks')
|
||||
IssueRelation.create!(issue_from: issues[14], issue_to: issues[18], relation_type: 'precedes')
|
||||
|
||||
# Journals: notes and attribute changes
|
||||
issues.first(15).each_with_index do |issue, i|
|
||||
issue.reload.init_journal(people[i % 3], "Looked into this. #{i.even? ? 'Needs a decision from **product**.' : 'Fix is in review.'}")
|
||||
issue.done_ratio = [issue.done_ratio.to_i + 10, 100].min unless issue.closed?
|
||||
issue.save!
|
||||
end
|
||||
showcase.watcher_users << people[1..2]
|
||||
|
||||
# Attachments
|
||||
# 160x90 solid-color RGB PNG, built by hand so the seed needs no image file
|
||||
png_chunk = ->(type, data) { [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') }
|
||||
png = "\x89PNG\r\n\x1a\n".b + png_chunk.('IHDR', [160, 90, 8, 2, 0, 0, 0].pack('NNC5')) +
|
||||
png_chunk.('IDAT', Zlib::Deflate.deflate(([0] + [103, 80, 164] * 160).pack('C*') * 90)) + png_chunk.('IEND', '')
|
||||
[['screenshot.png', png, 'image/png'], ['notes.txt', "Some notes\n", 'text/plain']].each do |name, data, type|
|
||||
Attachment.create!(file: StringIO.new(data), author: admin, filename: name, content_type: type, container: showcase)
|
||||
end
|
||||
|
||||
# Wiki
|
||||
wiki = apollo.reload.wiki
|
||||
start_text = <<~MD
|
||||
# Apollo wiki
|
||||
|
||||
{{toc}}
|
||||
|
||||
## Overview
|
||||
|
||||
The customer portal lets clients see **tickets**, *invoices* and `usage`.
|
||||
|
||||
> Keep this page short. Details go into child pages.
|
||||
|
||||
## Team
|
||||
|
||||
| Name | Role | Since |
|
||||
|------|------|-------|
|
||||
| Jane Doe | Product owner | 2024 |
|
||||
| Max Smith | Backend | 2025 |
|
||||
| Linh Nguyen | Frontend | 2026 |
|
||||
|
||||
## Setup
|
||||
|
||||
```ruby
|
||||
class Portal
|
||||
def initialize(user)
|
||||
@user = user # current user
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] Install dependencies
|
||||
- [x] Configure database
|
||||
|
||||
See [[Architecture]] and issue #13.
|
||||
MD
|
||||
page = WikiPage.new(wiki: wiki, title: wiki.start_page)
|
||||
page.content = WikiContent.new(page: page, text: start_text, author: admin)
|
||||
page.save!
|
||||
child = WikiPage.new(wiki: wiki, title: 'Architecture', parent: page)
|
||||
child.content = WikiContent.new(page: child, text: "# Architecture\n\nThree tiers: web, API, database.", author: people[1])
|
||||
child.save!
|
||||
|
||||
# News, forum, documents
|
||||
news = News.create!(project: apollo, title: 'Version 2.0 beta available', summary: 'Try the new portal',
|
||||
description: 'The beta is live on staging. Please report issues.', author: people[0])
|
||||
Comment.create!(commented: news, author: people[1], content: 'Great work!')
|
||||
|
||||
board = Board.create!(project: apollo, name: 'General', description: 'General discussion')
|
||||
topic = Message.create!(board: board, subject: 'Release planning', content: 'When do we freeze 2.0?', author: people[0])
|
||||
Message.create!(board: board, parent: topic, subject: 'RE: Release planning', content: 'Next Friday.', author: people[1])
|
||||
|
||||
Document.create!(project: apollo, title: 'Requirements', description: 'Signed-off requirements.',
|
||||
category: DocumentCategory.first)
|
||||
|
||||
# Time entries over the last 30 days
|
||||
activities = TimeEntryActivity.active.to_a
|
||||
issues.first(20).each_with_index do |issue, i|
|
||||
user = people[i % 3]
|
||||
TimeEntry.create!(project: issue.project, issue: issue, user: user, author: user, hours: [0.5, 1, 2, 3.5][i % 4],
|
||||
spent_on: today - (i % 30), activity: activities[i % activities.size], comments: 'Work on issue')
|
||||
end
|
||||
|
||||
# Saved public query: grouped, with totals
|
||||
IssueQuery.create!(name: 'Open by assignee', user: admin, visibility: Query::VISIBILITY_PUBLIC,
|
||||
group_by: 'assigned_to', totalable_names: %w[estimated_hours spent_hours],
|
||||
column_names: %i[tracker status priority subject assigned_to due_date done_ratio])
|
||||
end
|
||||
|
||||
puts "Seeded #{Project.count} projects, #{Issue.count} issues, #{User.count} users."
|
||||
|
||||
load File.join(__dir__, 'spread_activity.rb')
|
||||
load File.join(__dir__, 'seed_extras.rb')
|
||||
@@ -0,0 +1,67 @@
|
||||
# Extra demo data for pages the base seed does not cover: repository, wiki diff, description diff.
|
||||
# Loaded by seed.rb. Each block has its own guard, so rerunning the seed adds only what is missing.
|
||||
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
require 'tmpdir'
|
||||
|
||||
apollo = Project.find_by!(identifier: 'apollo')
|
||||
admin = User.find_by!(login: 'admin')
|
||||
User.current = admin
|
||||
|
||||
# Git repository: built in a work tree, then cloned bare into the files volume (persists restarts).
|
||||
unless apollo.repository
|
||||
bare = '/usr/src/redmine/files/demo-repo.git'
|
||||
work = Dir.mktmpdir
|
||||
git = lambda do |*args|
|
||||
out, status = Open3.capture2e('git', '-C', work, *args)
|
||||
raise "git #{args.join(' ')} failed: #{out}" unless status.success?
|
||||
end
|
||||
env = { 'user.name' => 'Jane Doe', 'user.email' => 'jdoe@example.com' }.flat_map { |k, v| ['-c', "#{k}=#{v}"] }
|
||||
|
||||
git.('init', '-q', '-b', 'main')
|
||||
File.write(File.join(work, 'README.md'), "# Apollo\n\nCustomer portal.\n")
|
||||
FileUtils.mkdir_p(File.join(work, 'lib'))
|
||||
File.write(File.join(work, 'lib/portal.rb'), "class Portal\n def initialize(user)\n @user = user\n end\nend\n")
|
||||
git.(*env, 'add', '.')
|
||||
git.(*env, 'commit', '-q', '-m', 'Initial import')
|
||||
File.write(File.join(work, 'lib/portal.rb'),
|
||||
"class Portal\n def initialize(user, locale = :en)\n @user = user\n @locale = locale\n end\n\n def greeting\n \"Hello, \#{@user}\"\n end\nend\n")
|
||||
git.(*env, 'commit', '-q', '-am', 'Add locale and greeting (refs #13)')
|
||||
File.write(File.join(work, 'README.md'), "# Apollo\n\nCustomer portal.\n\n## Setup\n\nRun `bin/setup`.\n")
|
||||
git.(*env, 'commit', '-q', '-am', 'Document setup')
|
||||
|
||||
FileUtils.rm_rf(bare)
|
||||
out, status = Open3.capture2e('git', 'clone', '-q', '--bare', work, bare)
|
||||
raise "git clone failed: #{out}" unless status.success?
|
||||
FileUtils.rm_rf(work)
|
||||
FileUtils.chown_R('redmine', 'redmine', bare) if Process.uid.zero? # the app runs as redmine
|
||||
|
||||
apollo.enabled_module_names = apollo.enabled_module_names | ['repository']
|
||||
repo = Repository::Git.create!(project: apollo, url: bare, identifier: 'portal', is_default: true)
|
||||
repo.fetch_changesets
|
||||
end
|
||||
|
||||
# Second wiki version, so history and diff have something to compare
|
||||
start = apollo.wiki.find_page(apollo.wiki.start_page)
|
||||
if start && start.content.version == 1
|
||||
start.content.text = start.content.text.sub('Keep this page short.', 'Keep this page short and current.') +
|
||||
"\n## Release\n\nVersion 2.0 ships at the end of the sprint.\n"
|
||||
start.content.author = User.find_by!(login: 'jdoe')
|
||||
start.content.comments = 'Add release section'
|
||||
start.content.save!
|
||||
end
|
||||
|
||||
# Description change on the showcase issue, so the journal has a "diff" link
|
||||
showcase = Issue.find(13) # the issue the screenshot checklist shows
|
||||
unless showcase.journals.joins(:details).exists?(journal_details: { prop_key: 'description' })
|
||||
showcase.init_journal(User.find_by!(login: 'msmith'))
|
||||
showcase.description = "#{showcase.description}\n\nAlso happens in the API search endpoint."
|
||||
showcase.save!
|
||||
end
|
||||
|
||||
# Second forum, so the forum index lists boards instead of redirecting to the only one
|
||||
Board.create!(project: apollo, name: 'Support', description: 'Questions from customers') if apollo.boards.count < 2
|
||||
|
||||
puts "Extras: #{Repository.count} repositories, wiki v#{start&.content&.reload&.version}, " \
|
||||
"#{JournalDetail.where(prop_key: 'description').count} description diffs, #{apollo.boards.count} forums."
|
||||
@@ -0,0 +1,60 @@
|
||||
# Spreads demo record timestamps over the last two weeks (working hours), so the activity page,
|
||||
# "updated" columns and history show different days and times. Deterministic (based on ids),
|
||||
# safe to run again. Called by seed.rb; can also run alone:
|
||||
# docker compose exec redmine bin/rails runner /seed/spread_activity.rb
|
||||
|
||||
now = Time.current
|
||||
today = Date.current
|
||||
DAYS = 14
|
||||
|
||||
# Day `days_ago` at a working-hour time derived from `n`
|
||||
at = lambda do |days_ago, n|
|
||||
(today - days_ago).in_time_zone.change(hour: 8 + (n * 7) % 10, min: (n * 13) % 60)
|
||||
end
|
||||
not_future = ->(t, n) { t < now ? t : now - (n % 90 + 5).minutes }
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
Issue.order(:id).each_with_index do |issue, i|
|
||||
created = at.(DAYS - 1 - (i % DAYS), i)
|
||||
last = created
|
||||
issue.journals.order(:id).each do |journal|
|
||||
t = not_future.(created + (1 + journal.id % 3).days + (journal.id * 17 % 300).minutes, journal.id)
|
||||
journal.update_columns(created_on: t, updated_on: t)
|
||||
last = [last, t].max
|
||||
end
|
||||
closed = issue.closed? ? [last, created + 1.day].max : nil
|
||||
issue.update_columns(created_on: created, updated_on: last, closed_on: closed && not_future.(closed, issue.id))
|
||||
end
|
||||
|
||||
TimeEntry.find_each do |entry|
|
||||
t = not_future.(entry.spent_on.in_time_zone.change(hour: 17, min: entry.id * 7 % 60), entry.id)
|
||||
entry.update_columns(created_on: t, updated_on: t)
|
||||
end
|
||||
|
||||
Attachment.find_each { |a| a.update_columns(created_on: not_future.(a.container.try(:created_on).to_time + 2.hours, a.id)) if a.container }
|
||||
|
||||
WikiContentVersion.order(:id).each_with_index do |version, i|
|
||||
t = at.(9 - i * 2, version.id)
|
||||
version.update_columns(updated_on: t)
|
||||
version.page.content.update_columns(updated_on: t) if version.version == version.page.content.version
|
||||
end
|
||||
WikiPage.find_each { |page| page.update_columns(created_on: page.content.versions.minimum(:updated_on) || page.created_on) }
|
||||
|
||||
News.find_each do |news|
|
||||
news.update_columns(created_on: at.(6, news.id))
|
||||
news.comments.each_with_index { |c, i| c.update_columns(created_on: at.(5 - i, c.id), updated_on: at.(5 - i, c.id)) }
|
||||
end
|
||||
|
||||
Message.where(parent_id: nil).find_each do |topic|
|
||||
topic.update_columns(created_on: at.(4, topic.id), updated_on: at.(4, topic.id))
|
||||
topic.children.each_with_index do |reply, i|
|
||||
t = not_future.(at.(3 - i, reply.id), reply.id)
|
||||
reply.update_columns(created_on: t, updated_on: t)
|
||||
topic.update_columns(updated_on: t)
|
||||
end
|
||||
end
|
||||
|
||||
Document.find_each { |d| d.update_columns(created_on: at.(8, d.id)) }
|
||||
end
|
||||
|
||||
puts "Spread activity over the last #{DAYS} days."
|
||||
Reference in New Issue
Block a user