├── .circleci └── config.yml ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── acts_as_tracked.gemspec ├── bin ├── console └── setup ├── lib ├── acts_as_tracked.rb ├── acts_as_tracked │ ├── activity.rb │ ├── extenders │ │ └── trackable.rb │ ├── tracking.rb │ └── version.rb └── generators │ └── acts_as_tracked │ └── migration │ ├── migration_generator.rb │ └── templates │ └── active_record │ └── migration.erb └── spec ├── acts_as_tracked_spec.rb ├── internal ├── app │ └── assets │ │ └── config │ │ └── manifest.js ├── config │ ├── database.yml │ ├── routes.rb │ ├── schema.rb │ └── storage.yml ├── db │ └── schema.rb ├── log │ └── .gitignore └── public │ └── favicon.ico ├── lib └── tracking_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | ruby: circleci/ruby@0.1.2 4 | 5 | jobs: 6 | build: 7 | docker: 8 | - image: circleci/ruby:2.6.3-stretch-node 9 | environment: 10 | RAILS_ENV: test 11 | PGHOST: 127.0.0.1 12 | PGUSER: root 13 | 14 | - image: circleci/postgres:9.6.2-alpine 15 | environment: 16 | POSTGRES_USER: root 17 | POSTGRES_DB: acts_as_tracked_test 18 | 19 | executor: ruby/default 20 | steps: 21 | - checkout 22 | 23 | - restore_cache: 24 | name: Restore bundle cache 25 | keys: 26 | - acts-as-tracked-dependencies 27 | 28 | - run: 29 | name: Install Bundler 30 | command: gem install bundler 31 | 32 | - run: 33 | name: Bundle Install 34 | command: | 35 | bundle install --jobs=4 --retry=3 --path vendor/bundle 36 | 37 | - run: sudo apt install -y postgresql-client || true 38 | 39 | - save_cache: 40 | name: Store bundle cache 41 | paths: 42 | - ./vendor/bundle 43 | key: acts-as-tracked-dependencies 44 | 45 | - run: 46 | name: Rubocop check 47 | command: bundle exec rubocop 48 | 49 | - run: 50 | name: Test Suite 51 | command: PREPARE_COMBUSTION_DB_USING_SCHEMA=true bundle exec rspec 52 | 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | DisplayCopNames: true 4 | NewCops: enable 5 | Style/Documentation: 6 | Enabled: false 7 | Metrics/BlockLength: 8 | Enabled: false 9 | Metrics/AbcSize: 10 | Enabled: false -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at sahil.gadimbay@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in acts_as_tracked.gemspec 6 | gemspec 7 | 8 | gem 'rake', '~> 12.0' 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | acts_as_tracked (1.0.0) 5 | actionview (>= 4.2) 6 | activerecord (>= 4.2) 7 | activesupport (>= 4.2) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actionpack (6.0.3.2) 13 | actionview (= 6.0.3.2) 14 | activesupport (= 6.0.3.2) 15 | rack (~> 2.0, >= 2.0.8) 16 | rack-test (>= 0.6.3) 17 | rails-dom-testing (~> 2.0) 18 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 19 | actionview (6.0.3.2) 20 | activesupport (= 6.0.3.2) 21 | builder (~> 3.1) 22 | erubi (~> 1.4) 23 | rails-dom-testing (~> 2.0) 24 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 25 | activemodel (6.0.3.2) 26 | activesupport (= 6.0.3.2) 27 | activerecord (6.0.3.2) 28 | activemodel (= 6.0.3.2) 29 | activesupport (= 6.0.3.2) 30 | activesupport (6.0.3.2) 31 | concurrent-ruby (~> 1.0, >= 1.0.2) 32 | i18n (>= 0.7, < 2) 33 | minitest (~> 5.1) 34 | tzinfo (~> 1.1) 35 | zeitwerk (~> 2.2, >= 2.2.2) 36 | ast (2.4.1) 37 | builder (3.2.4) 38 | codecov (0.1.17) 39 | json 40 | simplecov 41 | url 42 | combustion (1.3.0) 43 | activesupport (>= 3.0.0) 44 | railties (>= 3.0.0) 45 | thor (>= 0.14.6) 46 | concurrent-ruby (1.1.6) 47 | crass (1.0.6) 48 | diff-lcs (1.4.2) 49 | docile (1.3.2) 50 | erubi (1.9.0) 51 | i18n (1.8.3) 52 | concurrent-ruby (~> 1.0) 53 | json (2.3.0) 54 | loofah (2.6.0) 55 | crass (~> 1.0.2) 56 | nokogiri (>= 1.5.9) 57 | method_source (1.0.0) 58 | mini_portile2 (2.4.0) 59 | minitest (5.14.1) 60 | nokogiri (1.10.9) 61 | mini_portile2 (~> 2.4.0) 62 | parallel (1.19.2) 63 | parser (2.7.1.4) 64 | ast (~> 2.4.1) 65 | pg (1.2.3) 66 | rack (2.2.3) 67 | rack-test (1.1.0) 68 | rack (>= 1.0, < 3) 69 | rails-dom-testing (2.0.3) 70 | activesupport (>= 4.2.0) 71 | nokogiri (>= 1.6) 72 | rails-html-sanitizer (1.3.0) 73 | loofah (~> 2.3) 74 | railties (6.0.3.2) 75 | actionpack (= 6.0.3.2) 76 | activesupport (= 6.0.3.2) 77 | method_source 78 | rake (>= 0.8.7) 79 | thor (>= 0.20.3, < 2.0) 80 | rainbow (3.0.0) 81 | rake (12.3.3) 82 | regexp_parser (1.7.1) 83 | rexml (3.2.4) 84 | rspec-core (3.9.2) 85 | rspec-support (~> 3.9.3) 86 | rspec-expectations (3.9.2) 87 | diff-lcs (>= 1.2.0, < 2.0) 88 | rspec-support (~> 3.9.0) 89 | rspec-mocks (3.9.1) 90 | diff-lcs (>= 1.2.0, < 2.0) 91 | rspec-support (~> 3.9.0) 92 | rspec-rails (4.0.1) 93 | actionpack (>= 4.2) 94 | activesupport (>= 4.2) 95 | railties (>= 4.2) 96 | rspec-core (~> 3.9) 97 | rspec-expectations (~> 3.9) 98 | rspec-mocks (~> 3.9) 99 | rspec-support (~> 3.9) 100 | rspec-support (3.9.3) 101 | rubocop (0.85.1) 102 | parallel (~> 1.10) 103 | parser (>= 2.7.0.1) 104 | rainbow (>= 2.2.2, < 4.0) 105 | regexp_parser (>= 1.7) 106 | rexml 107 | rubocop-ast (>= 0.0.3) 108 | ruby-progressbar (~> 1.7) 109 | unicode-display_width (>= 1.4.0, < 2.0) 110 | rubocop-ast (0.0.3) 111 | parser (>= 2.7.0.1) 112 | ruby-progressbar (1.10.1) 113 | simplecov (0.18.5) 114 | docile (~> 1.1) 115 | simplecov-html (~> 0.11) 116 | simplecov-html (0.12.2) 117 | thor (1.0.1) 118 | thread_safe (0.3.6) 119 | tzinfo (1.2.7) 120 | thread_safe (~> 0.1) 121 | unicode-display_width (1.7.0) 122 | url (0.3.2) 123 | with_model (2.1.4) 124 | activerecord (>= 5.2, < 6.1) 125 | zeitwerk (2.3.0) 126 | 127 | PLATFORMS 128 | ruby 129 | 130 | DEPENDENCIES 131 | acts_as_tracked! 132 | codecov 133 | combustion (~> 1.3) 134 | pg 135 | rake (~> 12.0) 136 | rspec-rails 137 | rubocop 138 | simplecov 139 | with_model 140 | 141 | BUNDLED WITH 142 | 2.1.4 143 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Sahil Gadimbayli 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/acts_as_tracked.svg)](https://badge.fury.io/rb/acts_as_tracked) 2 | [![CircleCI](https://circleci.com/gh/ramblingcode/acts-as-tracked.svg?style=svg)](https://circleci.com/gh/ramblingcode/acts-as-tracked) 3 | [![codecov](https://codecov.io/gh/ramblingcode/acts-as-tracked/branch/master/graph/badge.svg)](https://codecov.io/gh/ramblingcode/acts-as-tracked) 4 | 5 | 6 | # ActsAsTracked 7 | 8 | Welcome to ActsAsTracked! This gem is an extension to your ActiveRecord models to track activities. It does not track everything all the time or in the background, but can be used wherever you find it necessary to have a history for changes alongside their actors. 9 | 10 | There are few other gems such as [audited](https://github.com/collectiveidea/audited), however, it is tracking every change on your models. ActsAsTracked is controlled by `you` and will track changes only when `used explicitly`. 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'acts_as_tracked' 18 | ``` 19 | 20 | And then execute: 21 | 22 | ```shell 23 | bundle install 24 | ``` 25 | 26 | Or install it yourself as: 27 | 28 | ```shell 29 | gem install acts_as_tracked 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Create Activities table 35 | 36 | First, you would need to generate a migration to create Activities table in your database. 37 | 38 | ActsAsTracked has extension to generate migrations. Please, run: 39 | 40 | ```shell 41 | bundle exec rails generate acts_as_tracked:migration 42 | ``` 43 | 44 | This will generate following migration: 45 | 46 | ```ruby 47 | class ActsAsTrackedMigration < ActiveRecord::Migration 48 | def self.up 49 | create_table :activities do |t| 50 | t.references :actor, polymorphic: true 51 | t.references :subject, polymorphic: true 52 | t.references :parent, polymorphic: true 53 | t.text :attribute_changes 54 | t.string :activity_type 55 | t.string :human_description 56 | t.timestamps null: false 57 | end 58 | end 59 | 60 | def self.down 61 | drop_table :activity 62 | end 63 | end 64 | ``` 65 | 66 | Run `bundle exec rails db:migrate` and let's move on to the usage. 67 | 68 | ### Call acts_as_tracked in your AR model 69 | 70 | If you would like to track changes of `Post` model, you would need to call `acts_as_tracked` in it. 71 | 72 | ```ruby 73 | class Post < ApplicationRecord 74 | acts_as_tracked 75 | 76 | # You may optionally pass in exluded_activity_attributes 77 | # as an argument to not track given fields. 78 | # 79 | # acts_as_tracked(exclude_activity_attributes: %i[api_key, username]) 80 | end 81 | ``` 82 | 83 | ### Tracking Changes 84 | 85 | Now, you are able to track changes on Post. 86 | 87 | ```ruby 88 | @post = Post.first 89 | 90 | Post.tracking_changes(actor: User.find(1)) do 91 | @post.update( 92 | content: 'New Content' 93 | ) 94 | end 95 | ``` 96 | 97 | Let's try to get activities for Post we just changed: 98 | 99 | ```ruby 100 | @post.activities 101 | 102 | [ 103 | #["Great post content.", "New Content"]}, 112 | activity_type: "updated", 113 | human_description: nil, 114 | created_at: Thu, 25 Jun 2020 12:03:39 UTC +00:00, 115 | updated_at: Thu, 25 Jun 2020 12:03:39 UTC +00:00> 116 | ] 117 | ``` 118 | 119 | ## More features 120 | 121 | You can check activities for the record in which it was an Actor: 122 | 123 | ```ruby 124 | @post.activities_as_actor 125 | 126 | ...activities 127 | ``` 128 | 129 | You can check activities for the record in which it was a Subject: 130 | 131 | ```ruby 132 | @post.activities_as_subject 133 | 134 | ...activities 135 | ``` 136 | 137 | You can check activities for collection of records by passing ids: 138 | 139 | ```ruby 140 | Post.activities_for([post_id1, post_id2]) 141 | 142 | ...activities 143 | ``` 144 | 145 | ### Extra options to pass to `tracking_changes` call 146 | 147 | `tracking_changes` method can accept 3 arguments: 148 | 149 | 1. actor: mandatory -> actor record 150 | 2. subject: optional -> acting on record, defaults to record you are changing 151 | 3. parent: optional -> parent record for acting on record, defaults to nil 152 | 4. human_description: optional -> description you would like to include on change, defaults to `nil` 153 | 154 | ```ruby 155 | Post.tracking_changes(actor: User.first, subject: Post.first, human_description: 'Some description of change', parent: Post.first.parent) do 156 | ...your changes here 157 | end 158 | ``` 159 | 160 | ### Excluding attributes from change sets 161 | 162 | Sometimes you would like to not track some fields of the record in change sets. 163 | 164 | You can use exclude_activity_attributes and ActsAsTracked will not include them in activities. 165 | 166 | ```ruby 167 | class Post < ApplicationRecord 168 | acts_as_tracked(exclude_activity_attributes: %i[api_key, username]) 169 | end 170 | ``` 171 | 172 | ### Differences between [PaperTrail](https://github.com/paper-trail-gem/paper_trail), [Audited](https://github.com/collectiveidea/audited), and [PublicActivity](https://github.com/chaps-io/public_activity) gems 173 | 174 | Main difference is that ActsAsTracked does not track anything unless used `explicitly`. It does not store versions of the record that you can rollback to. 175 | 176 | It's minimal, and only does 1 thing, that is creating activity records with change sets whenever used. 177 | 178 | It's a feature extension to your models to use when *needed*, instead of being a hook that acts in the background. 179 | 180 | ```ruby 181 | Post.tracking_changes(actor: @user) do 182 | ...changes on posts 183 | end 184 | ``` 185 | 186 | You can check the `tracking` module which is just a shy over 100 lines that gets plugged in your models [here](https://github.com/ramblingcode/acts-as-tracked/blob/master/lib/acts_as_tracked/tracking.rb). 187 | 188 | ### Example application using ActsAsTracked 189 | 190 | I have created a Rails 6 application with the usages of ActsAsTracked. Please refer to this [repo](https://github.com/ramblingcode/rails6-acts-as-tracked-usage) 191 | 192 | ## Credits 193 | 194 | Initial work of ActsAsTracked has been done by [@rogercampos](https://github.com/rogercampos) and [@camaloon](https://github.com/camaloon) team. I have refined, packaged, documented, added generators and published it. 195 | 196 | ## Development 197 | 198 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 199 | 200 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 201 | 202 | ## Contributing 203 | 204 | Bug reports and pull requests are welcome on GitHub at https://github.com/ramblingcode/acts-as-tracked. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/acts_as_tracked/blob/master/CODE_OF_CONDUCT.md). 205 | 206 | ## License 207 | 208 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 209 | 210 | ## Code of Conduct 211 | 212 | Everyone interacting in the ActsAsTracked project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/acts_as_tracked/blob/master/CODE_OF_CONDUCT.md). 213 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /acts_as_tracked.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/acts_as_tracked/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'acts_as_tracked' 7 | spec.version = ActsAsTracked::VERSION 8 | spec.authors = ['Sahil Gadimbayli'] 9 | spec.email = ['sahil.gadimbay@gmail.com'] 10 | 11 | spec.summary = 'Activity Tracker to plug into ActiveRecord.' 12 | spec.description = 'Track activities in your ActiveRecord models.' 13 | spec.homepage = 'https://github.com/ramblingcode/acts-as-tracked' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0') 16 | 17 | spec.metadata['homepage_uri'] = spec.homepage 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | 25 | spec.bindir = 'exe' 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ['lib'] 28 | 29 | spec.add_dependency 'actionview', '>= 4.2' 30 | spec.add_dependency 'activerecord', '>= 4.2' 31 | spec.add_dependency 'activesupport', '>= 4.2' 32 | 33 | spec.add_development_dependency 'codecov' 34 | spec.add_development_dependency 'combustion', '~> 1.3' 35 | spec.add_development_dependency 'pg' 36 | spec.add_development_dependency 'rspec-rails' 37 | spec.add_development_dependency 'rubocop' 38 | spec.add_development_dependency 'simplecov' 39 | spec.add_development_dependency 'with_model' 40 | end 41 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'acts_as_tracked' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/acts_as_tracked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'acts_as_tracked/version' 4 | 5 | require 'active_record' 6 | require 'active_support' 7 | 8 | require 'acts_as_tracked/activity' 9 | require 'acts_as_tracked/extenders/trackable' 10 | 11 | module ActsAsTracked 12 | ActiveRecord::Base.extend ActsAsTracked::Extenders::Trackable 13 | end 14 | -------------------------------------------------------------------------------- /lib/acts_as_tracked/activity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsTracked 4 | class Activity < ::ActiveRecord::Base 5 | belongs_to :actor, polymorphic: true 6 | belongs_to :subject, polymorphic: true 7 | belongs_to :parent, polymorphic: true 8 | 9 | before_save { self[:attribute_changes] = self[:attribute_changes].with_indifferent_access } 10 | 11 | store :attribute_changes 12 | 13 | validates :subject, :actor, :activity_type, presence: true 14 | 15 | default_scope -> { order(arel_table[:created_at].desc) } 16 | 17 | def human_changes 18 | case activity_type 19 | when 'created' 20 | attribute_changes.reject { |_, values| values.last.to_s.blank? } 21 | when 'updated' 22 | attribute_changes.each { |_, values| values.map! { |v| v.presence || 'empty' } } 23 | when 'destroyed' 24 | attribute_changes.reject { |_, values| values.first.to_s.blank? } 25 | end 26 | end 27 | 28 | def subject_class 29 | @subject_class ||= subject_type&.constantize 30 | end 31 | 32 | def parent_class 33 | @parent_class ||= parent_type&.constantize 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/acts_as_tracked/extenders/trackable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # require 'active_support/concern' 4 | 5 | module ActsAsTracked 6 | module Extenders 7 | module Trackable 8 | # extend ActiveSupport::Concern 9 | 10 | # Includes Tracking module in specified model 11 | # 12 | # @params exclude_activity_attributes [:foo, :bar] 13 | # if given, excludes specified params from tracking 14 | 15 | def acts_as_tracked(args = {}) 16 | require 'acts_as_tracked/tracking' 17 | 18 | include ::ActsAsTracked::Tracking 19 | 20 | exclude_activity_attributes(*args[:exclude_activity_attributes]) if args[:exclude_activity_attributes] 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/acts_as_tracked/tracking.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/concern' 4 | 5 | module ActsAsTracked 6 | module Tracking 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | has_many :activities_as_subject, as: :subject, class_name: 'ActsAsTracked::Activity', dependent: :nullify 11 | has_many :activities_as_parent, as: :parent, class_name: 'ActsAsTracked::Activity', dependent: :nullify 12 | has_many :activities_as_actor, as: :actor, class_name: 'ActsAsTracked::Activity', dependent: :nullify 13 | 14 | after_create :track_create_activity!, if: :tracking_changes? 15 | after_update :track_update_activity!, if: :tracking_changes?, prepend: true 16 | after_destroy :track_destroy_activity!, if: :tracking_changes? 17 | 18 | cattr_accessor(:__global_activity_attributes) { {} } 19 | cattr_accessor(:__global_tracking_changes) { false } 20 | cattr_accessor(:__excluded_attributes) { %w[updated_at created_at] } 21 | end 22 | 23 | def activities 24 | t = Activity.arel_table 25 | Activity.where t[:subject_type].eq(self.class.name).and(t[:subject_id].eq(id)) 26 | .or(t[:parent_type].eq(self.class.name).and(t[:parent_id].eq(id))) 27 | end 28 | 29 | def tracking_changes(attributes) 30 | @activity_attributes = attributes 31 | @tracking_changes = true 32 | yield 33 | ensure 34 | @activity_attributes = {} 35 | @tracking_changes = false 36 | end 37 | 38 | def tracking_changes? 39 | @tracking_changes || self.class.__global_tracking_changes 40 | end 41 | 42 | def activity_attributes 43 | self.class.__global_activity_attributes.merge(@activity_attributes || {}) 44 | end 45 | 46 | def activity_label 47 | raise NotImplementedError, "You must define this method in #{self.class}" 48 | end 49 | 50 | class_methods do 51 | def tracking_changes(opts) 52 | self.__global_activity_attributes = __global_activity_attributes.merge(opts) 53 | self.__global_tracking_changes = true 54 | yield 55 | ensure 56 | self.__global_activity_attributes = {} 57 | self.__global_tracking_changes = false 58 | end 59 | 60 | def exclude_activity_attributes(*attributes) 61 | self.__excluded_attributes += attributes.map(&:to_s) 62 | end 63 | 64 | def activities_for(ids) 65 | ids = Array.wrap(ids) 66 | t = Activity.arel_table 67 | Activity.where t[:subject_type].eq(name).and(t[:subject_id].in(ids)) 68 | .or(t[:parent_type].eq(name).and(t[:parent_id].in(ids))) 69 | end 70 | end 71 | 72 | protected 73 | 74 | def track_activity!(type, defaults = {}) 75 | Activity.create!(defaults.merge(subject: self, activity_type: type)) 76 | end 77 | 78 | def track_create_activity! 79 | return true unless activity_changes.any? 80 | 81 | track_activity!(:created, activity_attributes.merge(attribute_changes: activity_changes)) 82 | true 83 | end 84 | 85 | def track_update_activity! 86 | return true unless activity_changes.any? 87 | 88 | track_activity!(:updated, activity_attributes.merge(attribute_changes: activity_changes)) 89 | true 90 | end 91 | 92 | def track_destroy_activity! 93 | track_activity!(:destroyed, activity_attributes) 94 | true 95 | end 96 | 97 | def activity_changes 98 | changes = saved_changes.transform_values(&:first).keys.reject do |x| 99 | self.class.__excluded_attributes.include?(x.to_s) 100 | end 101 | changes.map do |x| 102 | [x, [send("#{x}_before_last_save").to_s, self[x]]] 103 | end.to_h 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/acts_as_tracked/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsTracked 4 | VERSION = '1.0.2' 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/acts_as_tracked/migration/migration_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/migration' 4 | 5 | module ActsAsTracked 6 | class MigrationGenerator < Rails::Generators::Base 7 | include Rails::Generators::Migration 8 | MIGRATION_TIMESTAMP_FORMAT = '%Y%m%d%H%M%S' 9 | 10 | def self.source_root 11 | File.join(File.dirname(__FILE__), 'templates', 'active_record') 12 | end 13 | 14 | def self.next_migration_number(_path) 15 | Time.now.utc.strftime(MIGRATION_TIMESTAMP_FORMAT) 16 | end 17 | 18 | def create_migration_file 19 | migration_template 'migration.erb', 20 | 'db/migrate/acts_as_tracked_migration.rb' 21 | { migration_version: migration_version } 22 | end 23 | 24 | private 25 | 26 | # Rails 5.x+ requires Rails version to 27 | # be specified in migration file 28 | 29 | def migration_version 30 | migration_versions = { 31 | '5' => '[5.0]', 32 | '6' => '[6.0]' 33 | } 34 | 35 | migration_versions.fetch( 36 | Rails.version[0], 37 | nil 38 | ) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/generators/acts_as_tracked/migration/templates/active_record/migration.erb: -------------------------------------------------------------------------------- 1 | class ActsAsTrackedMigration < ActiveRecord::Migration<%= migration_version %> 2 | def self.up 3 | create_table :activities do |t| 4 | t.references :actor, polymorphic: true 5 | t.references :subject, polymorphic: true 6 | t.references :parent, polymorphic: true 7 | t.text :attribute_changes 8 | t.string :activity_type 9 | t.string :human_description 10 | t.timestamps null: false 11 | end 12 | end 13 | 14 | def self.down 15 | drop_table :activity 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/acts_as_tracked_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ActsAsTracked do 4 | it 'has a version number' do 5 | expect(ActsAsTracked::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/internal/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramblingcode/acts-as-tracked/4ccd1f409e03e53407a3d10f62e20c7f2093a6eb/spec/internal/app/assets/config/manifest.js -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | database: acts_as_tracked_test 4 | -------------------------------------------------------------------------------- /spec/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # Add your own routes here, or remove this file if you don't have need for it. 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/config/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | # These are extensions that must be enabled in order to support this database 5 | enable_extension 'plpgsql' 6 | 7 | create_table 'activities', force: :cascade do |t| 8 | t.integer 'actor_id' 9 | t.string 'actor_type' 10 | t.integer 'subject_id' 11 | t.string 'subject_type' 12 | t.integer 'parent_id' 13 | t.string 'parent_type' 14 | t.text 'attribute_changes' 15 | t.string 'activity_type' 16 | t.string 'human_description' 17 | t.datetime 'created_at', precision: 6, null: false 18 | t.datetime 'updated_at', precision: 6, null: false 19 | t.index ['actor_id'], name: 'index_activities_on_actor_id' 20 | t.index ['parent_id'], name: 'index_activities_on_parent_id' 21 | t.index ['subject_id'], name: 'index_activities_on_subject_id' 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/internal/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: /home/sgadimbayli/development/opensource/acts_as_tracked/tmp/storage 4 | -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | # These are extensions that must be enabled in order to support this database 5 | enable_extension 'plpgsql' 6 | 7 | create_table 'activities', force: :cascade do |t| 8 | t.integer 'actor_id' 9 | t.string 'actor_type' 10 | t.integer 'subject_id' 11 | t.string 'subject_type' 12 | t.integer 'parent_id' 13 | t.string 'parent_type' 14 | t.text 'attribute_changes' 15 | t.string 'activity_type' 16 | t.string 'human_description' 17 | t.datetime 'created_at', precision: 6, null: false 18 | t.datetime 'updated_at', precision: 6, null: false 19 | t.index ['actor_id'], name: 'index_activities_on_actor_id' 20 | t.index ['parent_id'], name: 'index_activities_on_parent_id' 21 | t.index ['subject_id'], name: 'index_activities_on_subject_id' 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /spec/internal/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramblingcode/acts-as-tracked/4ccd1f409e03e53407a3d10f62e20c7f2093a6eb/spec/internal/public/favicon.ico -------------------------------------------------------------------------------- /spec/lib/tracking_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'acts_as_tracked/tracking' 5 | 6 | RSpec.describe ActsAsTracked::Tracking do 7 | with_model :Actor do 8 | table do |t| 9 | t.text :name 10 | t.timestamps 11 | end 12 | end 13 | 14 | with_model :Klass do 15 | table do |t| 16 | t.text :name 17 | t.text :foo 18 | t.text :description 19 | t.timestamps 20 | end 21 | 22 | model do 23 | include ActsAsTracked::Tracking 24 | end 25 | end 26 | 27 | with_model :SecondKlass do 28 | table do |t| 29 | t.text :name 30 | t.timestamps 31 | end 32 | 33 | model do 34 | include ActsAsTracked::Tracking 35 | end 36 | end 37 | 38 | let(:actor) do 39 | Actor.create!(name: 'Actor') 40 | end 41 | 42 | it 'tracks activity on create' do 43 | a = Klass.new(name: 'Tom') 44 | 45 | expect do 46 | a.tracking_changes(actor: actor) { a.save! } 47 | end.to change(ActsAsTracked::Activity, :count).from(0).to(1) 48 | 49 | ActsAsTracked::Activity.last.tap do |activity| 50 | expect(activity.subject).to eq a 51 | expect(activity.actor).to eq actor 52 | expect(activity.activity_type).to eq 'created' 53 | expect(activity.attribute_changes).to eq('id' => ['', 1], 'name' => ['', 'Tom']) 54 | end 55 | end 56 | 57 | it 'tracks activity on update' do 58 | a = Klass.create!(name: 'Tom') 59 | 60 | expect do 61 | a.tracking_changes(actor: actor) { a.update!(name: 'Cucota') } 62 | end.to change(ActsAsTracked::Activity, :count).from(0).to(1) 63 | 64 | ActsAsTracked::Activity.last.tap do |activity| 65 | expect(activity.subject).to eq a 66 | expect(activity.actor).to eq actor 67 | expect(activity.activity_type).to eq 'updated' 68 | expect(activity.attribute_changes).to eq('name' => %w[Tom Cucota]) 69 | end 70 | end 71 | 72 | it 'tracks activity on deletion' do 73 | a = Klass.create!(name: 'Tom') 74 | id = a.id 75 | 76 | expect do 77 | a.tracking_changes(actor: actor) { a.destroy! } 78 | end.to change(ActsAsTracked::Activity, :count).from(0).to(1) 79 | 80 | ActsAsTracked::Activity.last.tap do |activity| 81 | expect(activity.subject).to eq nil 82 | expect(activity.subject_id).to eq id 83 | expect(activity.actor).to eq actor 84 | expect(activity.activity_type).to eq 'destroyed' 85 | expect(activity.attribute_changes).to eq({}) 86 | end 87 | end 88 | 89 | describe '.exclude_activity_attributes' do 90 | before do 91 | Klass.class_eval do 92 | exclude_activity_attributes :name, :description 93 | end 94 | end 95 | 96 | it 'allows do skip specific attributes from being tracked' do 97 | a = Klass.create!(name: 'Tom', foo: 'A') 98 | 99 | expect do 100 | a.tracking_changes(actor: actor) { a.update!(name: 'Cucota', foo: 'B') } 101 | end.to change(ActsAsTracked::Activity, :count).from(0).to(1) 102 | 103 | ActsAsTracked::Activity.last.tap do |activity| 104 | expect(activity.subject).to eq a 105 | expect(activity.actor).to eq actor 106 | expect(activity.activity_type).to eq 'updated' 107 | expect(activity.attribute_changes).to eq('foo' => %w[A B]) 108 | end 109 | end 110 | end 111 | 112 | it 'can track changes from the class' do 113 | expect do 114 | Klass.tracking_changes(actor: actor) do 115 | Klass.create!(name: 'Tom') 116 | end 117 | end.to change(ActsAsTracked::Activity, :count).from(0).to(1) 118 | 119 | ActsAsTracked::Activity.last.tap do |activity| 120 | expect(activity.actor).to eq actor 121 | expect(activity.activity_type).to eq 'created' 122 | expect(activity.attribute_changes).to eq('id' => ['', 1], 'name' => ['', 'Tom']) 123 | end 124 | end 125 | 126 | describe '.activities_for' do 127 | it 'returns activities for the given ids' do 128 | a = nil 129 | 130 | expect do 131 | Klass.tracking_changes(actor: actor) do 132 | a = Klass.create!(name: 'Tom') 133 | a.update!(name: 'Jerry') 134 | end 135 | end.to change(ActsAsTracked::Activity, :count).from(0).to(2) 136 | 137 | expect(Klass.activities_for([a.id]).size).to eq 2 138 | end 139 | 140 | it 'includes results where the subject is the parent' do 141 | subject = nil 142 | 143 | expect do 144 | Klass.tracking_changes(actor: actor) do 145 | subject = Klass.create!(name: 'Tom') 146 | subject.update!(name: 'Jerry') 147 | end 148 | 149 | SecondKlass.tracking_changes(actor: actor, parent: subject) do 150 | SecondKlass.create! name: 'AAA' 151 | end 152 | end.to change(ActsAsTracked::Activity, :count).from(0).to(3) 153 | 154 | expect(Klass.activities_for([subject.id]).size).to eq 3 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'acts_as_tracked' 5 | 6 | require 'simplecov' 7 | SimpleCov.start 8 | 9 | require 'codecov' 10 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 11 | 12 | require 'combustion' 13 | 14 | # ON CI Environment use internal/config/schema.rb 15 | 16 | database_reset, load_schema = if ENV['PREPARE_COMBUSTION_DB_USING_SCHEMA'] 17 | [true, true] 18 | else 19 | [false, false] 20 | end 21 | 22 | Combustion.initialize! :active_record, :active_support, database_reset: database_reset, load_schema: load_schema 23 | 24 | require 'rspec/rails' 25 | 26 | require 'with_model' 27 | 28 | RSpec.configure do |config| 29 | # Enable flags like --only-failures and --next-failure 30 | config.example_status_persistence_file_path = '.rspec_status' 31 | 32 | # Disable RSpec exposing methods globally on `Module` and `main` 33 | config.disable_monkey_patching! 34 | 35 | config.use_transactional_fixtures = true 36 | 37 | config.expect_with :rspec do |c| 38 | c.syntax = :expect 39 | end 40 | 41 | config.extend WithModel 42 | end 43 | --------------------------------------------------------------------------------