Initial Redmine tooling and local plugin forks

This commit is contained in:
Jason Thistlethwaite
2026-04-24 22:01:18 +00:00
commit 9f682af0eb
683 changed files with 56878 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/.cache/
/__pycache__/
/redmine-copy/
/dist/*.tar.gz
.env
*.pyc
+338
View File
@@ -0,0 +1,338 @@
# AGENT.md
This file is a restart brief for future agent sessions on this repository.
## Project Identity
This repository supports a legacy, business-critical Redmine 3.4.4
installation. The real purpose is to make CRM/helpdesk communication data
searchable and automatable without a risky near-term platform migration.
The LAN copy used for testing is:
```text
http://192.168.50.170/
```
This is not a greenfield app. Treat it as a local-fork-and-integration project
around an existing Redmine install and old RedmineUP plugins.
Tracked local plugin source lives in `plugins/`. The full `redmine-copy/` tree
is ignored and should be treated as a working/reference copy, not the source of
truth for local plugin changes.
## Original Motivation
The business stores most external communication inside Redmine through:
- `redmine_contacts`
- `redmine_contacts_helpdesk`
The default UI/API is not good enough for:
- efficient contact search
- light contact maintenance
- helpdesk message search
- customer communication timeline lookup
- future semantic/vector search
The key realization from prior work:
```text
Helpdesk-created issues may have Anonymous issue authors.
The real customer identity lives in helpdesk_tickets, journal_messages, and contacts.
```
Never design the search/index layer as if `issues.author` were enough.
## High-Level Architecture Decision
The intended architecture is:
1. Redmine writes local outbox rows.
2. External worker reads those rows.
3. Worker enriches from read-only MySQL joins.
4. Worker builds ticket/message documents.
5. External index stores/searches those documents.
Safety rule:
```text
External failures must not break Redmine saves.
```
That is why the event boundary is local DB outbox, not request-time webhooks or
embedding calls.
## Redmine And Plugin Baseline
- Redmine version: `3.4.4`
- Old RedmineUP plugin versions in use:
- `redmine_contacts` 4.1.2 PRO
- `redmine_contacts_helpdesk` 3.0.9 PRO
- Near-term upgrade to newer Redmine/RedmineUP is not the current goal.
- Treat these plugins as local legacy code when necessary.
## Repository Landmarks
Top-level docs:
- [README.md](/home/iadnah/redmine/README.md:1)
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
Main scripts:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
Local Redmine copy:
- [redmine-copy](/home/iadnah/redmine/redmine-copy)
Important local plugin paths:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk)
## What Has Already Been Done
### Contact CLI
`redmine_contacts.py` exists and supports:
- contact fetch to local cache
- fuzzy-ish contact search
- light contact updates
- helpdesk read API calls:
- ticket by issue
- issues by contact
- messages by issue
- contact timeline
### Event Outbox Plugin
`redmine_event_outbox` exists and the known-good archive is:
- `dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz`
Known-good tested LAN events:
- `issue.created`
- `issue.updated`
- `journal.created`
- `contact.created`
- `contact.updated`
Local code also adds optional helpdesk events:
- `helpdesk_ticket.created`
- `helpdesk_ticket.updated`
- `journal_message.created`
- `journal_message.updated`
These helpdesk event paths are implemented locally but should still be treated
as needing fuller end-to-end validation.
### Helpdesk Read API In Local Plugin Fork
The local `redmine_contacts_helpdesk` fork includes:
- `helpdesk_search_controller.rb`
- routes under `/helpdesk_search/*`
- alias/usage routes to avoid noisy routing errors
This was deployed to the LAN copy and route-loaded successfully.
### Helpdesk Export/Search CLI
`redmine_helpdesk_search.py` was created to prove the data model and export
path. It:
- SSHes to the LAN Redmine host
- reads remote MySQL credentials from `config/database.yml`
- runs read-only MySQL queries
- exports ticket/message docs to local JSONL cache
- provides rough local search/timeline/issues-by-contact commands
Important:
```text
This script is a diagnostic/export tool, not the final search architecture.
```
Do not get dragged into treating local CLI search speed as the core mission.
### External Outbox Worker Prototype
`redmine_outbox_worker.py` now exists as the first worker/indexer boundary.
It:
- reads and optionally claims pending `event_outbox_events` over SSH/MySQL
- supports `--dry-run` for non-mutating previews
- uses `locked_at`, `locked_by`, `processed_at`, `attempts`, and `last_error`
- enriches helpdesk ticket/message/contact events with read-only joins
- writes derived JSONL to `.cache/redmine_outbox/derived_documents.jsonl`
- marks rows processed only after a successful local write
This is still a prototype output target, not the final vector index.
## LAN Host Facts
These were verified previously:
- SSH host/user: `reddev@192.168.50.170`
- SSH key used previously: `/tmp/reddev`
- remote Redmine path: `/usr/share/redmine`
- remote Ruby: `2.5.1`
- remote Bundler: `1.17.3`
- remote DB: MySQL 5.7
- remote plugin rollback archives were stored in:
- `/home/reddev/redmine-plugin-backups/`
If using those exact credentials again, verify they still exist before relying
on them.
## Remote Plugin Changes Previously Deployed
Changes were copied to the LAN Redmine host for:
- `plugins/redmine_event_outbox/...`
- `plugins/redmine_contacts_helpdesk/...`
Passenger was restarted with:
```text
touch /usr/share/redmine/tmp/restart.txt
```
Routes were verified by remote `rake routes`.
## Fork Hygiene Rules
Before editing RedmineUP plugin code:
1. create a rollback archive in `dist/`
2. record the change in:
- `docs/redmineup_local_fork_changelog.md`
- plugin-local changelog if appropriate
3. keep edits scoped
4. prefer read-only APIs/endpoints first
5. validate on the LAN copy before claiming the change is done
Existing rollback archives:
- `dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- `dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz`
## Important Pre-Existing Issues
Read:
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
Especially remember:
1. duplicate `id="avatar"` is likely a real long-standing UI bug
2. `attachments/contacts_thumbnail` digest warning is probably log noise
3. `acts_as_list` warning is an upgrade-compatibility note, not a current
blocker
Do not confuse those with the main search deliverable unless they directly block
the work in front of you.
## Current Strategic State
The project is past the "can we get data out?" phase.
We now know:
- contact JSON access exists
- helpdesk/customer identity can be extracted
- outbox events can be recorded safely
- helpdesk read API routes can be added locally
- full ticket/message export is feasible
The most useful next step is:
```text
Build the external worker/indexer pipeline.
```
Not:
- polishing a local CLI into a full search product
- building a big Redmine admin UI
- chasing unrelated legacy plugin cleanup
## Recommended Next Steps For A Future Session
1. Re-read:
- `README.md`
- `docs/event_outbox_spec.md`
- `docs/redmineup_local_fork_changelog.md`
- `docs/pre_existing_issues.md`
2. Verify local and remote state:
- plugin files present
- LAN host reachable
- remote routes still load
3. Validate `redmine_outbox_worker.py` end to end against the LAN copy
4. Refine read-only joins that enrich:
- `helpdesk_tickets`
- `journal_messages`
- `contacts`
- `issues`
- `journals`
5. Confirm deterministic ticket/message/contact JSONL output
6. Only after that, connect Qdrant and embeddings
## Query/Data Model Notes
Authoritative helpdesk context lives in:
- `helpdesk_tickets`
- `issue_id`
- `contact_id`
- `from_address`
- `to_address`
- `cc_address`
- `message_id`
- `ticket_date`
- `source`
- `journal_messages`
- `journal_id`
- `contact_id`
- `from_address`
- `to_address`
- `cc_address`
- `bcc_address`
- `message_id`
- `is_incoming`
- `message_date`
- `contacts`
- `issues`
- `journals`
The worker/indexer should prefer direct DB joins as the source of truth for this
layer.
## What To Be Careful About
- Do not assume Redmine core issue API data is enough for helpdesk search.
- Do not treat old RedmineUP code as untouchable vendor code.
- Do not claim a plugin change is validated just because `ruby -c` passes.
- Do not burn time optimizing local CLI search if the real goal is external
indexing.
- Do not revert unrelated repo changes; the Redmine tree may be dirty.
## If You Need A One-Paragraph Summary
This project exists to safely extract and search real CRM/helpdesk communication
history from a legacy Redmine 3.4.4 install. We already have a contact CLI, a
local event outbox plugin, local helpdesk read API routes, and a rough
helpdesk-export CLI. The next meaningful milestone is an external worker that
consumes outbox rows, enriches from helpdesk/contact MySQL tables, and builds
deterministic ticket/message documents for a future Qdrant/OpenAI-backed search
index.
+427
View File
@@ -0,0 +1,427 @@
# Legacy Redmine Search And Integration Project
This repository exists to make a heavily customized, business-critical Redmine
3.4.4 installation easier to work with without forcing a risky near-term
platform upgrade.
The original production-like copy used for testing is on the LAN at:
```text
http://192.168.50.170/
```
This project started because a large amount of customer/vendor communication is
stored in Redmine through the old RedmineUP CRM and Helpdesk plugins, but the
default UI and documented APIs are not good enough for operational search,
contact lookup, message history review, and future automation.
The goal is not "modernize Redmine" in one jump. The goal is to create safe,
pragmatic tooling around the existing system so the business can search and use
its real communication history.
## Why This Project Exists
The Redmine install has been tightly integrated into day-to-day business
operations. It stores:
- CRM contacts
- helpdesk tickets
- helpdesk email conversations
- issue status and assignment history
- internal journal notes
The old RedmineUP plugin stack is effectively local legacy code now:
- `redmine_contacts` 4.1.2 PRO
- `redmine_contacts_helpdesk` 3.0.9 PRO
Tracked local plugin source lives under:
- [plugins](/home/iadnah/redmine/plugins)
The full `redmine-copy/` tree is an ignored working/reference copy of the legacy
install. Make local plugin changes in `plugins/` first, then deploy or copy them
into the test Redmine instance or `redmine-copy/` as needed.
There is no realistic short-term plan to:
- migrate to a newer RedmineUP package
- upgrade cleanly to Redmine 6
- replace the whole system before extracting value from the data already inside
it
So this repository treats those plugins as maintainable local forks where needed.
## Core Problem We Are Solving
The main operational need is better search over real customer/vendor
communications.
The crucial discovery was that core Redmine issue data is not sufficient on its
own. In the helpdesk workflow, the issue author can be `Anonymous`, while the
actual customer identity and email metadata live in:
- `helpdesk_tickets`
- `journal_messages`
- `contacts`
That means any serious search/indexing system must treat helpdesk data as
first-class and must not rely only on Redmine issue API fields.
## Architecture Direction
The working architecture is:
1. Redmine records low-risk local outbox events.
2. An external worker reads outbox rows.
3. The worker enriches those rows with read-only MySQL joins against
helpdesk/contact tables.
4. The worker builds ticket-level and message-level documents.
5. Those documents are indexed externally, eventually with vector search
support.
The important safety rule is:
```text
External search/indexing failures must not break normal Redmine saves.
```
That is why the event boundary lives in a local DB outbox table rather than in
request-time webhooks, brokers, or direct embedding calls.
## What Has Been Finished
### 1. Contact API Exploration And CLI
The old RedmineUP contacts plugin already exposes useful JSON routes such as:
```text
/projects/customer-service/contacts.json
```
That led to the first standalone helper:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
It currently supports:
- fetching contacts to a local cache
- fuzzy-ish contact search over cached JSON
- light contact updates through the Redmine API
- helpdesk metadata lookup through the local read-only helpdesk API routes
### 2. Event Outbox Plugin
A small plugin was created at:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
It records local database events into `event_outbox_events`.
Known-good archive:
- [dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz)
- [manifest](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md:1)
Tested event types on the LAN copy:
- `issue.created`
- `issue.updated`
- `journal.created`
- `contact.created`
- `contact.updated`
Planned/implemented locally but not fully LAN-validated as a complete workflow:
- `helpdesk_ticket.created`
- `helpdesk_ticket.updated`
- `journal_message.created`
- `journal_message.updated`
### 3. Local Helpdesk Plugin Fork Changes
We made targeted changes to the local fork of `redmine_contacts_helpdesk`:
- added a read-only JSON controller:
- [helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
- added routes for:
- ticket by issue
- issues by contact
- messages by issue
- contact timeline
- added shorter alias/usage routes to avoid noisy routing errors in logs
- guarded those endpoints with the existing `view_helpdesk_tickets` permission
These changes were deployed to the LAN Redmine copy and route-loaded
successfully.
### 4. Local Fork Hygiene
Before touching the RedmineUP plugin forks, rollback archives were created:
- [redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz)
- [redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz)
Manifests:
- [contacts manifest](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
- [helpdesk manifest](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
Change tracking docs:
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md:1)
### 5. Read-Only Helpdesk Export/Search CLI
We also built:
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
Purpose:
- prove that helpdesk/customer/message data can be pulled correctly using
read-only SQL joins
- produce ticket-level and message-level documents locally
- provide rough CLI search/timeline tooling for debugging and validation
Current state:
- works over SSH to the LAN Redmine host
- reads MySQL credentials from remote `config/database.yml`
- fetches helpdesk ticket and journal message documents into local JSONL cache
- can search/timeline/issues-by-contact over the local cache
Important note:
This script is a diagnostic/export tool, not the final search architecture. We
intentionally stopped short of treating CLI speed optimization as the main goal.
### 6. External Outbox Worker Prototype
The first external worker prototype is:
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
It runs outside Redmine and consumes `event_outbox_events` over SSH/MySQL. The
initial output target is deterministic local JSONL rather than a live search
service:
- default output: `.cache/redmine_outbox/derived_documents.jsonl`
Current behavior:
- dry-runs pending rows without marking them processed
- claims bounded batches with `locked_at` and `locked_by`
- enriches helpdesk ticket/message/contact-related events with read-only joins
- writes derived event/ticket/message/contact documents as JSONL
- marks rows processed only after a successful local write
- increments `attempts` and writes `last_error` when processing fails
### 7. Test Helpdesk Mail Reset
After importing a production database into the LAN test instance, reset all
Helpdesk-enabled projects to use the local Mailpit test mailbox with:
- [reset_helpdesk_mail_settings.py](/home/iadnah/redmine/reset_helpdesk_mail_settings.py:1)
Preview the affected projects and settings:
```sh
./reset_helpdesk_mail_settings.py --dry-run
```
Apply the reset:
```sh
./reset_helpdesk_mail_settings.py
```
Defaults match the current Mailpit test setup:
- incoming POP3: `192.168.1.105:1110`
- outgoing SMTP: `192.168.1.105:1025`
- incoming username/password: `test` / `testpass`
- outgoing SMTP authentication: none
- answer-from pattern: `helpdesk-{identifier}@example.test`
If Mailpit moves, pass the host that Redmine can reach:
```sh
./reset_helpdesk_mail_settings.py --mailpit-host 192.168.50.170
```
## LAN Deployment Progress
The LAN Redmine copy at `192.168.50.170` was inspected and updated via SSH.
Confirmed environment facts:
- SSH user used: `reddev`
- remote Redmine path: `/usr/share/redmine`
- remote Ruby: `2.5.1`
- remote Bundler: `1.17.3`
- remote DB config: MySQL via `config/database.yml`
Remote plugin rollback archives were created on the LAN host under:
```text
/home/reddev/redmine-plugin-backups/
```
The deployed helpdesk search routes were verified with:
- remote `rake routes`
- HTTP requests returning normal login redirects when unauthenticated
## What Is Not Finished Yet
### 1. The Real Worker/Indexer
This is the main unfinished piece.
Still needed:
- run and document end-to-end validation of `redmine_outbox_worker.py` against
the LAN copy
- decide the first real external index target
- map the derived JSONL document shape into that index
### 2. External Search Index
We have not yet built the actual external index.
Planned direction:
- Qdrant first
- OpenAI embeddings first
- ticket-level docs for "which issue mentioned this?"
- message-level docs for "how did we handle a similar case?"
### 3. Full Helpdesk Event Validation
The local code includes helpdesk outbox hooks and read-only helpdesk API
changes, but the complete create/update test matrix for:
- `helpdesk_ticket.*`
- `journal_message.*`
still needs to be run and documented cleanly on the LAN copy.
### 4. Pre-Existing UI/Plugin Bugs
We discovered old plugin issues while working:
- duplicate avatar DOM ids likely causing repeated/wrong thumbnails
- `attachments/contacts_thumbnail` digest warning
- `acts_as_list` Redmine 4 compatibility warning
These are logged in:
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
They are important context but are not the primary search deliverable.
## Current Repo Files That Matter Most
Project docs:
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
Tooling:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
Local plugin work:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
## Current Recommended Next Steps
If continuing this project, the next best work is:
1. Stop treating the CLI exporter as the end product.
2. Build the external worker that consumes `event_outbox_events`.
3. Start with a simple derived output target:
- JSONL
- SQLite
- or local files
4. Then connect that worker to:
- Qdrant
- OpenAI embeddings
5. Validate end-to-end helpdesk search using real historical message data.
## Practical Commands
Fetch contacts:
```sh
./redmine_contacts.py fetch
```
Search contacts:
```sh
./redmine_contacts.py search "customer name or phone"
```
Preview a contact update:
```sh
./redmine_contacts.py update 123 --set first_name=Corrected
```
Fetch helpdesk metadata through the Redmine read API:
```sh
./redmine_contacts.py helpdesk-ticket 39858
./redmine_contacts.py helpdesk-issues 4337 --limit 50
./redmine_contacts.py helpdesk-messages 39858 --limit 100
./redmine_contacts.py helpdesk-timeline 4337 --limit 100
```
Fetch read-only helpdesk documents directly over SSH/MySQL:
```sh
./redmine_helpdesk_search.py fetch
```
Preview pending outbox events and their derived documents without marking them
processed:
```sh
./redmine_outbox_worker.py --dry-run --batch-size 10
```
Process a bounded outbox batch into local JSONL and mark successful rows:
```sh
./redmine_outbox_worker.py --batch-size 20
```
Search the cached helpdesk documents:
```sh
./redmine_helpdesk_search.py search "inventory not updated" --type message --limit 10
```
Show issues by contact:
```sh
./redmine_helpdesk_search.py issues-by-contact 1299 --limit 20
```
## Summary
This project began as a way to search and use legacy Redmine data without
breaking the system or forcing a full upgrade. The important progress so far is
not cosmetic UI work. It is the architectural clarification that helpdesk data
is the authoritative customer communication layer, plus the first safe event and
export tooling around it.
The repo is now at the point where the next meaningful leap is an external
worker/indexer, not more Redmine UI surface.
@@ -0,0 +1,13 @@
# redmine_contacts 4.1.2 Local Snapshot
- Archive: `redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- Created: `2026-04-21T21:55:48Z`
- Source directory: `redmine-copy/plugins/redmine_contacts`
- Purpose: rollback snapshot before local helpdesk search/outbox work.
- SHA256:
```text
527562df67a4a38e0f80282d5ee897c13a5eceabe183c7cdc49dc6471a327fe9 dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz
```
No code changes were made to `redmine_contacts` in this work item.
@@ -0,0 +1,14 @@
# redmine_contacts_helpdesk 3.0.9 Local Snapshot
- Archive: `redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- Created: `2026-04-21T21:55:48Z`
- Source directory: `redmine-copy/plugins/redmine_contacts_helpdesk`
- Purpose: rollback snapshot before local helpdesk search/outbox work.
- SHA256:
```text
6ed55e3e5918c283a684c682ef4bf7e772a0156315b1c40f084a7bcf7e672b89 dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz
```
This snapshot predates the local read-only `helpdesk_search/*` endpoints and
the optional `redmine_event_outbox` helpdesk callbacks.
@@ -0,0 +1,47 @@
# redmine_event_outbox 0.0.1 Known-Good Archive
Archive:
```text
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz
```
SHA256:
```text
e7f31491554cbcf87fdb49ea92ecccc24265952616e661ff6a45cc9b8c172dbc
```
Contents:
```text
redmine_event_outbox/
redmine_event_outbox/README.md
redmine_event_outbox/app/models/event_outbox_event.rb
redmine_event_outbox/db/migrate/001_create_event_outbox_events.rb
redmine_event_outbox/init.rb
redmine_event_outbox/lib/redmine_event_outbox.rb
redmine_event_outbox/lib/redmine_event_outbox/event.rb
redmine_event_outbox/lib/redmine_event_outbox/hooks/issues_hook.rb
redmine_event_outbox/lib/redmine_event_outbox/patches/contact_patch.rb
redmine_event_outbox/lib/redmine_event_outbox/patches/journal_patch.rb
redmine_event_outbox/lib/tasks/redmine_event_outbox.rake
```
Tested on LAN Redmine copy:
- Migrated successfully.
- Passenger restart via `touch tmp/restart.txt`.
- Verified `issue.created`.
- Verified `issue.updated`.
- Verified `journal.created`.
- Verified `contact.created`.
- Verified `contact.updated`.
- Verified `redmine_event_outbox:dump`.
Test artifacts left on LAN copy for traceability:
- Issue `#39858` in project `Meetings`.
- Contact `#4337` in project `Email Test`.
- Six outbox rows in `event_outbox_events`.
+588
View File
@@ -0,0 +1,588 @@
# Redmine Event Outbox Spec
## Purpose
Add a low-risk event boundary around the legacy Redmine install so external tools
can react to Redmine changes without polling heavily and without making Redmine
dependent on a message bus, search service, or experimental automation code.
The first version should capture issue and journal activity into a local database
outbox table. A separate worker can later publish those rows to Redis, RabbitMQ,
a webhook, or a search/indexing service.
## Goals
- Record issue creation and update events.
- Record issue journal/comment events.
- Record contact creation and update events from `redmine_contacts`.
- Keep Redmine issue saves working even if external messaging/search systems are
down.
- Make event processing replayable from a durable local table.
- Keep the plugin small and compatible with Redmine 3.4.4-era plugin patterns.
- Support future indexing for queries like "recent events for customer A".
## Non-Goals For V1
- No direct Redis/RabbitMQ publish inside Redmine request callbacks.
- No semantic/vector search inside Redmine.
- No replacement of Redmine email handling in the first version.
- No guarantee that external consumers receive each event exactly once.
- No broad UI in Redmine beyond optional admin/status views later.
- No binary image indexing inside Redmine.
## Safety Rule
Redmine must not fail an issue create/update because a broker, worker, search
index, DNS lookup, network call, or external service failed.
V1 should therefore only write to Redmine's own database from inside Redmine. All
network publishing happens outside Redmine in a worker.
There is one important tradeoff:
- If outbox insert failures are rescued, ticket saves are maximally protected but
rare event loss is possible.
- If outbox rows are written transactionally and failures are not rescued, event
durability is stronger but ticket saves can fail because the outbox write
failed.
For this project, prefer protecting ticket saves. The outbox insert should be
simple and local, and failures should be logged with enough detail to diagnose.
## Proposed Plugin
Plugin name:
```text
redmine_event_outbox
```
Directory:
```text
redmine-copy/plugins/redmine_event_outbox/
```
Initial structure:
```text
init.rb
db/migrate/001_create_event_outbox_events.rb
app/models/event_outbox_event.rb
lib/redmine_event_outbox.rb
lib/redmine_event_outbox/hooks/issues_hook.rb
lib/redmine_event_outbox/patches/contact_patch.rb
lib/redmine_event_outbox/patches/journal_patch.rb
lib/tasks/redmine_event_outbox.rake
```
## Event Table
Table name:
```text
event_outbox_events
```
Columns:
```text
id integer primary key
event_type string, required
source_type string, required
source_id integer, required
project_id integer, nullable
issue_id integer, nullable
journal_id integer, nullable
user_id integer, nullable
occurred_at datetime, required
payload text/json, required
processed_at datetime, nullable
attempts integer, default 0
last_error text, nullable
locked_at datetime, nullable
locked_by string, nullable
created_at datetime
updated_at datetime
```
Production Redmine currently uses MySQL. Store `payload` as `text` for Redmine
3.4.4 compatibility and serialize JSON in application code. Do not rely on
native MySQL JSON behavior in V1.
Useful indexes:
```text
index_event_outbox_events_on_processed_at_id
index_event_outbox_events_on_event_type
index_event_outbox_events_on_issue_id
index_event_outbox_events_on_project_id
index_event_outbox_events_on_occurred_at
```
## V1 Event Types
### issue.created
Created after a new issue is saved.
Trigger candidate found in Redmine 3.4.4:
```ruby
controller_issues_new_after_save
```
Payload:
```json
{
"event_type": "issue.created",
"issue_id": 123,
"project_id": 4,
"tracker_id": 1,
"status_id": 1,
"priority_id": 2,
"author_id": 5,
"author_name": "Jane User",
"assigned_to_id": null,
"assigned_to_name": null,
"subject": "Example",
"created_on": "2026-04-21T12:00:00Z",
"updated_on": "2026-04-21T12:00:00Z"
}
```
### issue.updated
Created after an existing issue update succeeds.
Trigger candidate found in Redmine 3.4.4:
```ruby
controller_issues_edit_after_save
```
Payload should include issue identity plus the current journal id when present:
```json
{
"event_type": "issue.updated",
"issue_id": 123,
"journal_id": 456,
"project_id": 4,
"status_id": 2,
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"actor_id": 5,
"actor_name": "Jane User",
"subject": "Example",
"updated_on": "2026-04-21T12:15:00Z"
}
```
### journal.created
Created when a journal row for an issue is committed.
Trigger candidate found in Redmine 3.4.4:
```ruby
Journal.after_commit :on => :create
```
The plugin can patch `Journal` with a separate `after_commit` callback.
Payload:
```json
{
"event_type": "journal.created",
"journal_id": 456,
"issue_id": 123,
"project_id": 4,
"user_id": 5,
"user_name": "Jane User",
"subject": "Example",
"private_notes": false,
"has_notes": true,
"changed_fields": ["status_id", "assigned_to_id"],
"created_on": "2026-04-21T12:15:00Z"
}
```
V1 should not put full private note text into the payload. The indexing worker can
fetch detail with appropriate credentials if needed.
### contact.created
Created after a contact is committed.
Trigger candidate:
```ruby
Contact.after_commit :on => :create
```
The plugin can patch `Contact` from `redmine_contacts` when that plugin is
installed.
Payload:
```json
{
"event_type": "contact.created",
"contact_id": 321,
"project_ids": [4],
"is_company": false,
"name": "Customer Name",
"company": "Customer Company",
"author_id": 5,
"author_name": "Jane User",
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"created_on": "2026-04-21T12:20:00Z",
"updated_on": "2026-04-21T12:20:00Z"
}
```
### contact.updated
Created after a contact update is committed.
Trigger candidate:
```ruby
Contact.after_commit :on => :update
```
Payload:
```json
{
"event_type": "contact.updated",
"contact_id": 321,
"project_ids": [4],
"is_company": false,
"name": "Customer Name",
"company": "Customer Company",
"actor_id": 5,
"actor_name": "Jane User",
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"updated_on": "2026-04-21T12:25:00Z"
}
```
Contact payloads should include enough human context for downstream consumers to
decide whether to fetch the full contact. Avoid embedding all phone/email/address
data in V1 payloads unless a consumer proves it needs those fields inline.
### helpdesk_ticket.created
Created after a `HelpdeskTicket` row is committed when
`redmine_contacts_helpdesk` is installed.
Payload:
```json
{
"event_type": "helpdesk_ticket.created",
"helpdesk_ticket_id": 10,
"issue_id": 123,
"project_id": 4,
"contact_id": 321,
"message_id": "<message@example>",
"is_incoming": true,
"source": 0,
"from_address": "customer@example.com",
"to_address": "support@example.com",
"cc_address": null,
"subject": "Example",
"ticket_date": "2026-04-21T12:00:00Z"
}
```
### helpdesk_ticket.updated
Created after an existing `HelpdeskTicket` row is updated. This is useful when
the contact, source, or message metadata is corrected after ticket creation.
### journal_message.created
Created after a `JournalMessage` row is committed. This is the authoritative
per-email metadata layer for helpdesk conversation search.
Payload:
```json
{
"event_type": "journal_message.created",
"journal_message_id": 20,
"journal_id": 456,
"issue_id": 123,
"project_id": 4,
"contact_id": 321,
"message_id": "<reply@example>",
"is_incoming": false,
"source": 0,
"from_address": "support@example.com",
"to_address": "customer@example.com",
"cc_address": null,
"has_bcc_address": false,
"private_notes": false,
"has_notes": true,
"message_date": "2026-04-21T12:15:00Z"
}
```
The event records whether BCC metadata exists but does not store the BCC address
itself.
### journal_message.updated
Created after an existing `JournalMessage` row is updated. This lets downstream
indexes repair message-level documents when message metadata is corrected.
## Payload Context Policy
Payloads should be lightweight but useful. Include:
- ids needed for follow-up fetches
- event type and timestamp
- issue/contact subject or display name
- user id and user name when known
- project id(s)
- changed field names when available
Avoid:
- full private notes
- large descriptions/background fields
- attachments or binary content
- full email bodies
- BCC addresses
- large custom field dumps
This gives consumers enough information to decide whether they care about an
event while keeping the outbox table compact and lower-risk.
## Delivery Semantics
V1 should provide at-least-once processing from the outbox to downstream systems.
Consumers must be idempotent. Use the outbox row id as the event id:
```json
{
"event_id": 98765,
"event_type": "issue.updated"
}
```
Duplicates are acceptable. Silent message loss is not acceptable once an outbox
row exists.
## Worker/Rake Task
Current implemented worker-facing command:
```sh
bundle exec rake redmine_event_outbox:dump RAILS_ENV=production
```
This prints pending rows as JSON and does not mark them processed.
Next worker command:
```sh
bundle exec rake redmine_event_outbox:publish RAILS_ENV=production
```
V1 modes:
```sh
# Print pending rows as JSON without marking processed.
bundle exec rake redmine_event_outbox:dump
# Process pending rows and mark success.
bundle exec rake redmine_event_outbox:publish
# Retry failed/unprocessed rows.
bundle exec rake redmine_event_outbox:publish RETRY=1
```
Initial publisher target can be stdout or a local JSONL file. That lets us test
the Redmine side before choosing Redis Streams or RabbitMQ.
The next implementation should keep the worker small and conservative:
- select pending rows in id order
- lock or claim a bounded batch
- publish each row
- mark `processed_at` only after publish succeeds
- increment `attempts` and write `last_error` on failure
- leave failed rows available for retry
- make duplicate delivery acceptable to consumers
Later publisher targets:
- Redis Streams
- RabbitMQ
- webhook HTTP POST
- local search/indexing service
## Search/Indexing Direction
The semantic/fuzzy search system should be external to Redmine.
The first external search index should have strong vector-search support because
future work will use embeddings heavily, including image embeddings. Redmine
should only emit events and identifiers; the external indexer should fetch,
transform, chunk, embed, and store searchable material.
Likely derived entities:
- contacts
- issues
- journals
- helpdesk email messages
- status/assignee changes
- timestamps and project links
- future image/document embeddings
Likely query examples:
```text
recent events for customer A
open issues involving customer A
recent emails from customer A
status changes for customer A this week
```
The outbox does not need to contain all searchable text. It only needs enough
identity and timestamps for a worker to fetch and update the external index.
Candidate vector-capable search/index options to evaluate later:
- PostgreSQL with `pgvector`
- Qdrant
- Weaviate
- OpenSearch/Elasticsearch with vector fields
- LanceDB
Since production Redmine uses MySQL, the search/index database should be treated
as a separate derived system rather than as a feature of the Redmine database.
## Email Handling Direction
Email handling should be handled after the basic issue/journal outbox is stable.
Observed areas to inspect further:
- Redmine core `MailHandler`
- `redmine_contacts` rake tasks under `lib/tasks/contacts_email.rake`
- `redmine_contacts_helpdesk`
- `helpdesk_mailer` endpoint and related mail handling code
Future event types may include:
```text
email.received
email.ignored
email.created_issue
email.updated_issue
helpdesk_ticket.created
helpdesk_ticket.updated
```
Custom mail routing should have a safe fallback to current behavior if external
decision code fails.
## Implementation Phases
### Phase 1: Outbox Skeleton
- Status: implemented and tested on the LAN Redmine copy.
- Created plugin.
- Added migration and model.
- Added helper method for safe event creation.
- Added issue create/update hooks.
- Added basic rake task to dump pending rows.
- Verified issue create/update writes outbox rows in the `Meetings` project.
### Phase 2: Journal Events
- Status: implemented and tested on the LAN Redmine copy.
- Patched `Journal` with `after_commit`.
- Records `journal.created`.
- Includes changed field names from journal details.
- Avoids full private note content in payload.
### Phase 3: Contact Events
- Status: implemented and tested on the LAN Redmine copy.
- Patched `Contact` from `redmine_contacts` if available.
- Records `contact.created`.
- Records `contact.updated`.
- Includes contact display name, company, project ids, and relevant user context.
- Keeps payloads small; full contact detail can be fetched by external workers.
### Phase 4: Worker
- Add locking fields.
- Process pending rows in batches.
- Mark `processed_at` only after successful publish.
- Track attempts and last error.
- Support dry-run/dump mode.
### Phase 5: External Index Prototype
- Build a small external worker outside Redmine.
- Read outbox rows.
- Fetch affected issue/contact/journal details through API or DB.
- Store in a vector-capable search database.
- Add a CLI for recent customer timelines.
### Phase 6: Message Bus
- Choose Redis Streams or RabbitMQ based on actual consumer needs.
- Keep Redmine unchanged; only the worker publisher changes.
## Open Questions
- How long should processed outbox rows be retained?
- Do we need a Redmine admin page to inspect outbox health, or is a rake task/log enough?
- What is the current production mail ingestion path?
- Which vector-capable external search index should be used first?
- Should contact payloads include normalized primary email/phone, or is that too much inline data?
## Current Checkpoint
Known-good archive:
```text
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz
```
Manifest:
```text
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md
```
Tested on LAN Redmine copy:
- Migrated successfully.
- Passenger restart via `touch tmp/restart.txt`.
- Verified `issue.created`.
- Verified `issue.updated`.
- Verified `journal.created`.
- Verified `contact.created`.
- Verified `contact.updated`.
- Verified `redmine_event_outbox:dump`.
Test artifacts left on LAN copy for traceability:
- Issue `#39858` in project `Meetings`.
- Contact `#4337` in project `Email Test`.
- Six outbox rows in `event_outbox_events`.
+94
View File
@@ -0,0 +1,94 @@
# Pre-Existing Issues Log
This log tracks bugs, warnings, and confusing behaviors noticed while working on
the Redmine 3.4.4 local fork. It is not a task tracker; it is a place to keep
context so future plugin edits do not have to rediscover old problems.
## 2026-04-21 - Duplicate Contact/Helpdesk Avatar IDs
- Area: `redmine_contacts`, `redmine_contacts_helpdesk`
- Status: observed/analyzing
- Symptom: pages that display multiple contact/user avatars may show or behave
as if every row has the same thumbnail/avatar.
- Relevant code:
- `redmine_contacts/lib/redmine_contacts/helpers/contacts_helper.rb`
- helpdesk/contact views that call `link_to ..., :id => "avatar"` repeatedly
- Current assessment:
- Several views/helpers render repeated `id="avatar"` attributes on lists of
contacts, issues, notes, deals, and helpdesk journals.
- Repeated DOM ids are invalid HTML and can cause JavaScript, tooltip, popup,
or CSS selectors using `#avatar` to bind to the wrong element or reuse the
first matching element.
- This is a better fit for the long-standing "same thumbnail for every user"
symptom than the Rails cache digest warning below.
- Next diagnostic/fix idea:
- Replace repeated `:id => "avatar"` with `:class => "avatar"` where no unique
id is required.
- Where an id is required, generate stable unique ids such as
`contact-avatar-#{contact.id}` or `journal-avatar-#{journal.id}`.
- Test on a page that currently displays multiple contacts/users with distinct
avatars.
## 2026-04-21 - `attachments/contacts_thumbnail` Cache Digest Warning
- Area: `redmine_contacts`
- Status: observed/analyzing
- Log message:
```text
Couldn't find template for digesting: attachments/contacts_thumbnail
```
- Relevant code:
- `redmine_contacts/lib/redmine_contacts/patches/attachments_controller_patch.rb`
- route: `attachments/contacts_thumbnail/:id(/:size)`
- Current assessment:
- `AttachmentsController#contacts_thumbnail` streams a generated thumbnail via
`send_file` or returns 404; it normally does not render a view template.
- Rails 4 cache digesting still probes for a conventional action template and
logs the missing-template warning.
- This is likely log noise and probably not the cause of the duplicate-avatar
symptom.
- Possible fix:
- Add a blank placeholder template at
`redmine_contacts/app/views/attachments/contacts_thumbnail.html.erb` with a
local fork comment explaining that the action streams files.
- Do this only after confirming it does not mask the duplicate-avatar bug.
## 2026-04-21 - Helpdesk Search Manual URL Confusion
- Area: local `redmine_contacts_helpdesk` search API change
- Status: mitigated in current working copy and LAN deployment
- Symptom:
- Visiting `/helpdesk_search/issues` or `/helpdesk_search/issues/1` produced
`ActionController::RoutingError` stack traces in `production.log`.
- Current assessment:
- The originally implemented API route was
`/helpdesk_search/issues/:issue_id/ticket`.
- Manual browser tests naturally tried the shorter paths.
- Mitigation:
- Added usage routes for `/helpdesk_search`, `/helpdesk_search/issues`, and
`/helpdesk_search/contacts`.
- Added `/helpdesk_search/issues/:issue_id` as an alias for the ticket lookup.
## 2026-04-21 - `acts_as_list` Redmine 4 Deprecation Warning
- Area: Redmine core/plugin compatibility
- Status: observed; low urgency while Redmine 3.4.4 remains the baseline
- Log message:
```text
DEPRECATION WARNING: The acts_as_list plugin will be removed from Redmine 4 core, use the acts_as_list gem or similar implementation instead. (called from acts_as_list at /usr/share/redmine/lib/plugins/acts_as_list/lib/active_record/acts/list.rb:34)
```
- Current assessment:
- This is an upgrade-compatibility warning from the Redmine/Rails stack, not a
current runtime failure.
- It means some installed plugin or model uses `acts_as_list` from Redmine
core. Redmine 4 removes that bundled implementation, so any future Redmine 4+
migration would need an explicit `acts_as_list` gem or a local replacement.
- Since the near-term baseline is Redmine 3.4.4 and upgrading is not currently
a goal, this should not block helpdesk search work.
- Next diagnostic/fix idea:
- If upgrade work resumes, search installed plugins and app models for
`acts_as_list`, then decide whether to add the gem or patch each caller.
+85
View File
@@ -0,0 +1,85 @@
# RedmineUP Local Fork Changelog
The installed RedmineUP `redmine_contacts` and `redmine_contacts_helpdesk`
plugins are treated as locally maintained legacy code for this Redmine 3.4.4
environment. Before risky edits, archive the current plugin directories in
`dist/` and record the purpose, touched behavior, and LAN test result here.
## Current Checkpoint
- Baseline:
- Redmine `3.4.4`
- `redmine_contacts` `4.1.2 PRO`
- `redmine_contacts_helpdesk` `3.0.9 PRO`
- Strategic direction:
- Treat helpdesk/customer data as first-class.
- Prefer local-fork plugin edits when they unlock safer search/indexing.
- Keep Redmine request paths independent from external worker/index failures.
- Implemented locally:
- `redmine_event_outbox` plugin with issue/journal/contact events.
- Optional helpdesk outbox hooks for `HelpdeskTicket` and `JournalMessage`.
- Read-only `helpdesk_search/*` JSON endpoints in the local helpdesk fork.
- Standalone contact CLI and read-only helpdesk export/search CLI.
- LAN deployment status:
- Helpdesk search routes were deployed and route-loaded successfully on the
LAN Redmine copy.
- Short alias/usage routes were added to avoid noisy routing errors during
manual browser testing.
- Full end-to-end helpdesk outbox validation is still pending.
- Next meaningful milestone:
- Build the external worker/indexer that consumes `event_outbox_events`,
enriches via read-only MySQL joins, and emits deterministic ticket/message
documents for external indexing.
## 2026-04-24 - POP3 Get Mail Compatibility Fix
- Touched plugin:
- `redmine_contacts`
- `redmine_contacts_helpdesk`
- Purpose:
- Fix Helpdesk POP3 retrieval on the LAN test host when Ruby 2.5 raises
`FrozenError: can't modify frozen String` inside `Net::POP3`.
- Allow Helpdesk outbound mail to use Mailpit's unauthenticated SMTP listener.
- Behavior changed:
- Changed POP3 message retrieval from `msg.pop` to `msg.pop(String.new)` so
Ruby's POP3 code appends chunks into an explicit mutable destination string.
- This does not change message handling semantics; it only avoids relying on
Ruby's default empty string argument being mutable.
- Changed Helpdesk SMTP delivery option construction to omit
`authentication`, `user_name`, and `password` when the project SMTP
authentication setting is blank.
- LAN test result:
- Deployed to `/usr/share/redmine/plugins/redmine_contacts`.
- `HelpdeskMailer.check_project(Project.find("fud-helpdesk").id)` completed
successfully and processed 1 message.
- Deployed to `/usr/share/redmine/plugins/redmine_contacts_helpdesk`.
- Mailpit rejected `AUTH PLAIN` with `502 5.5.1 Command not implemented`.
After blanking SMTP auth settings and omitting auth options, a Helpdesk test
mail for issue `#39863` was delivered to Mailpit.
## 2026-04-21 - Helpdesk Search Foundation
- Archives created before plugin edits:
- `dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- `dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- Touched plugins:
- `redmine_contacts_helpdesk`
- `redmine_event_outbox`
- Purpose:
- Make helpdesk ticket and message identity available to external search and
indexing workers.
- Avoid relying on Redmine issue author when helpdesk-created tickets use
`Anonymous`.
- Behavior changed:
- Added read-only `helpdesk_search/*` JSON endpoints guarded by the existing
`view_helpdesk_tickets` permission.
- Added optional outbox hooks for `HelpdeskTicket` and `JournalMessage`.
- Payload/content policy:
- Include ids, source, direction, message id, and non-body address metadata.
- Do not copy email bodies, private note text, attachments, or BCC addresses
into event rows or the read API.
- LAN test result:
- Pending. Validate on the LAN Redmine copy by creating/updating a controlled
helpdesk ticket and journal message, checking `event_outbox_events`, and
calling the new `helpdesk_search/*` endpoints with a user/API key that has
`view_helpdesk_tickets`.
+14
View File
@@ -0,0 +1,14 @@
# Local Redmine Plugin Forks
This directory is the tracked source of truth for local Redmine plugin work.
The ignored `redmine-copy/` tree is a working/reference copy of the full legacy
Redmine install. Make plugin edits here first, then deploy or copy them into the
test Redmine instance or `redmine-copy/` as needed.
Tracked plugin folders:
- `redmine_event_outbox` - local event outbox plugin.
- `redmine_contacts` - RedmineUP contacts plugin with local compatibility fixes.
- `redmine_contacts_helpdesk` - RedmineUP helpdesk plugin with local API and
mail compatibility fixes.
+3
View File
@@ -0,0 +1,3 @@
gem "redmine_crm"
gem "vcard", "~> 0.2.8"
gem "spreadsheet", "~> 0.6.8"
+46
View File
@@ -0,0 +1,46 @@
= Contacts plugin
== Install
* Copy redmine_contacts plugin to {RAILS_APP}/plugins on your redmine path
* Run bundle install --without development test RAILS_ENV=production
* Run rake redmine:plugins NAME=redmine_contacts RAILS_ENV=production
== Uninstall
<pre>
rake redmine:plugins NAME=redmine_contacts VERSION=0 RAILS_ENV=production
rm -r plugins/redmine_contacts
</pre>
=== Tables created by CRM plugin
* contacts
* contacts_deals
* contacts_issues
* contacts_projects
* deals
* deal_categories
* deal_processes
* deal_statuses
* deal_statuses_projects
* notes
* tags
* taggings
* recently_vieweds
* contacts_settings
* contacts_queries
* addresses
* deals_issues
== Requirements
* Redmine 2.3+
== Test
bundle exec rake db:drop db:create db:migrate redmine:plugins RAILS_ENV=test_sqlite3
bundle exec rake test TEST="plugins/redmine_contacts/test/**/*_test.rb" RAILS_ENV=test_sqlite3
=== Test API
curl -v -H "Content-Type: application/xml" -X POST --data "@contact.xml" -u admin:admin http://localhost:3000/contacts.xml
@@ -0,0 +1,36 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactImportsController < ImporterBaseController
menu_item :contacts
helper :contacts
def klass
ContactImport
end
def importer_klass
ContactKernelImport
end
def instance_index
project_contacts_path(:project_id => @project.id)
end
end
@@ -0,0 +1,490 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsController < ApplicationController
unloadable
Mime::Type.register 'text/x-vcard', :vcf
Mime::Type.register 'application/vnd.ms-excel', :xls
default_search_scope :contacts
before_action :find_contact, :only => [:show, :edit, :update, :destroy, :load_tab]
before_action :find_project, :only => [:new, :create]
before_action :authorize, :only => [:create, :new]
before_action :authorize_contacts, :only => [:edit, :update, :destroy]
before_action :find_optional_project, :only => [:index, :contacts_notes, :edit_mails, :send_mails, :bulk_update]
accept_rss_auth :index, :show
accept_api_auth :index, :show, :create, :update, :destroy
helper :attachments
helper :contacts
include ContactsHelper
helper :watchers
helper :deals
helper :notes
helper :custom_fields
include CustomFieldsHelper
helper :context_menus
include WatchersHelper
helper :sort
include SortHelper
helper :queries
include QueriesHelper
helper :crm_queries
include CrmQueriesHelper
include ApplicationHelper
include NotesHelper
def index
retrieve_crm_query('contact')
sort_init(@query.sort_criteria.empty? ? [['last_name', 'asc'], ['first_name', 'asc']] : @query.sort_criteria)
sort_update(@query.sortable_columns)
@query.sort_criteria = sort_criteria.to_a
if @query.valid?
case params[:format]
when 'csv', 'xls', 'vcf'
@limit = Setting.issues_export_limit.to_i
if Redmine::VERSION::STRING < '3.2' && params[:columns] == 'all'
@query.column_names = @query.available_columns.map(&:name)
end
when 'atom'
@limit = Setting.feeds_limit.to_i
when 'xml', 'json'
@offset, @limit = api_offset_and_limit
else
@limit = per_page_option
end
@contacts_count = @query.object_count
@contacts_pages = Paginator.new(@contacts_count, @limit, params['page'])
@offset ||= @contacts_pages.offset
@contact_count_by_group = @query.object_count_by_group
@contacts = @query.results_scope(
:include => [:avatar],
:search => params[:search],
:order => sort_clause,
:limit => @limit,
:offset => @offset
)
@filter_tags = @query.filters['tags'] && @query.filters['tags'][:values]
respond_to do |format|
format.html {
unless request.xhr?
last_notes
@tags = Contact.available_tags(:project => @project)
else
render :partial => contacts_list_style, :layout => false
end
}
format.api
format.atom { render_feed(@contacts, :title => "#{@project || Setting.app_title}: #{l(:label_contact_plural)}") }
format.csv {
send_data(query_to_csv(@contacts, @query, params[:csv] || {}),
:type => 'text/csv; header=present',
:filename => 'contacts.csv')
}
format.xls {
send_data(contacts_to_xls(@contacts),
:filename => 'contacts.xls',
:type => 'application/vnd.ms-excel',
:disposition => 'attachment')
}
format.vcf {
send_data(contacts_to_vcard(@contacts),
:filename => 'contacts.vcf',
:type => 'text/x-vcard',
:disposition => 'attachment')
}
end
else
respond_to do |format|
format.html {
last_notes
@tags = Contact.available_tags(:project => @project)
render(:template => 'contacts/index', :layout => !request.xhr?)
}
format.any(:atom, :csv, :pdf) { render(:nothing => true) }
format.api { render_validation_errors(@query) }
end
end
end
def show
find_contact_issues
respond_to do |format|
format.js if request.xhr?
format.html { @contact.viewed }
format.api
format.atom { render_feed(@notes, :title => "#{@contact.name || Setting.app_title}: #{l(:label_crm_note_plural)}") }
format.vcf { send_data(contact_to_vcard(@contact), :filename => "#{@contact.name}.vcf", :type => 'text/x-vcard;', :disposition => 'attachment') }
end
end
def edit
end
def update
@contact.safe_attributes = params[:contact]
@contact.save_attachments(params[:attachments] || (params[:contact] && params[:contact][:uploads]))
if @contact.save
flash[:notice] = l(:notice_successful_update)
remove_old_avatars
respond_to do |format|
format.html { redirect_to :action => 'show', :project_id => params[:project_id], :id => @contact }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { render 'edit', :project_id => params[:project_id], :id => @contact }
format.api { render_validation_errors(@contact) }
end
end
end
def destroy
if @contact.destroy
flash[:notice] = l(:notice_successful_delete)
else
flash[:error] = l(:notice_unsuccessful_save)
end
respond_to do |format|
format.html { redirect_back_or_default :action => 'index', :project_id => params[:project_id] }
format.api { render_api_ok }
end
end
def new
@duplicates = []
@contact = Contact.new
@contact.safe_attributes = params[:contact] if params[:contact] && params[:contact].is_a?(Hash)
end
def create
@contact = Contact.new(:project => @project, :author => User.current)
@contact.safe_attributes = params[:contact]
@contact.save_attachments(params[:attachments] || (params[:contact] && params[:contact][:uploads]))
if @contact.save
flash[:notice] = l(:notice_successful_create)
remove_old_avatars
respond_to do |format|
format.html { redirect_to (params[:continue] ? { :action => 'new', :project_id => @project } : { :action => 'show', :project_id => @project, :id => @contact }) }
format.js
format.api { redirect_on_create(params) }
end
else
respond_to do |format|
format.api { render_validation_errors(@contact) }
format.js { render :action => 'new' }
format.html { render :action => 'new' }
end
end
end
def contacts_notes
unless request.xhr?
@tags = Contact.available_tags(:project => @project)
end
contacts = find_contacts(false)
deals = find_deals
joins = " "
joins << " LEFT OUTER JOIN #{Contact.table_name} ON #{Note.table_name}.source_id = #{Contact.table_name}.id AND #{Note.table_name}.source_type = 'Contact' "
joins << " LEFT OUTER JOIN #{Deal.table_name} ON #{Note.table_name}.source_id = #{Deal.table_name}.id AND #{Note.table_name}.source_type = 'Deal' "
cond = "(1 = 1) "
cond << "and (#{Contact.table_name}.id in (#{contacts.any? ? contacts.map(&:id).join(', ') : 'NULL'})"
cond << " or #{Deal.table_name}.id in (#{deals.any? ? deals.map(&:id).join(', ') : 'NULL'}))"
cond << " and (LOWER(#{Note.table_name}.content) LIKE '%#{params[:search_note].downcase}%')" if params[:search_note] and request.xhr?
cond << " and (#{Note.table_name}.author_id = #{params[:note_author_id]})" if !params[:note_author_id].blank?
cond << " and (#{Note.table_name}.type_id = #{params[:type_id]})" if !params[:type_id].blank?
scope = Note.joins(joins).where(cond).order("#{Note.table_name}.created_on DESC")
@notes_pages = Paginator.new(scope.count, 20, params['page'])
@notes = scope.limit(20).offset(@notes_pages.offset)
respond_to do |format|
format.html { render :partial => "notes/notes_list", :layout => false, :locals => { :notes => @notes, :notes_pages => @notes_pages } if request.xhr? }
format.xml { render :xml => @notes }
format.csv { send_data(notes_to_csv(@notes), :type => 'text/csv; header=present', :filename => 'notes.csv') }
format.atom { render_feed(@notes, :title => "#{l(:label_crm_note_plural)}") }
end
end
def context_menu
@project = Project.find(params[:project_id]) unless params[:project_id].blank?
@contacts = Contact.visible.where(:id => params[:selected_contacts])
@contact = @contacts.first if (@contacts.size == 1)
@can = { :edit => (@contact && @contact.editable?) || (@contacts && @contacts.collect { |c| c.editable? }.inject { |memo, d| memo && d }),
:create_deal => (@project && User.current.allowed_to?(:add_deals, @project)),
:create => (@project && User.current.allowed_to?(:add_contacts, @project)),
:delete => @contacts.collect { |c| c.deletable? }.inject { |memo, d| memo && d },
:send_mails => @contacts.collect { |c| c.send_mail_allowed? && !c.primary_email.blank? }.inject { |memo, d| memo && d }
}
render :layout => false
end
def bulk_destroy
@contacts = Contact.deletable.where(:id => params[:ids])
raise ActiveRecord::RecordNotFound if @contacts.empty?
@contacts.each(&:destroy)
redirect_back_or_default({ :action => 'index', :project_id => params[:project_id] })
end
def bulk_edit
@contacts = Contact.editable.where(:id => params[:ids])
@projects = @contacts.collect { |p| p.projects.to_a.compact }.compact.flatten.uniq
raise ActiveRecord::RecordNotFound if @contacts.empty?
@custom_fields = ContactCustomField.order(:name)
@tag_list = RedmineCrm::TagList.from(@contacts.map(&:tag_list).inject { |memo, t| memo | t })
@project = @projects.first
@assignables = @projects.map(&:assignable_users).inject { |memo, a| memo & a }
@add_projects = Project.allowed_to(:edit_contacts).order(:lft)
end
def bulk_update
@contacts = Contact.editable.where(:id => params[:ids])
raise ActiveRecord::RecordNotFound if @contacts.empty?
unsaved_contact_ids = []
@contacts.each do |contact|
contact.reload
params[:contact][:tag_list] = (contact.tag_list + RedmineCrm::TagList.from(params[:add_tag_list]) - RedmineCrm::TagList.from(params[:delete_tag_list])).uniq
add_project_ids = (!params[:add_projects_list].to_s.blank? && params[:add_projects_list].is_a?(Array)) ? Project.allowed_to(:edit_contacts).where(:id => params[:add_projects_list].collect{|p| p.to_i}).map(&:id) : []
delete_project_ids = (!params[:delete_projects_list].to_s.blank? && params[:delete_projects_list].is_a?(Array)) ? Project.allowed_to(:edit_contacts).where(:id => params[:delete_projects_list].collect{|p| p.to_i}).map(&:id) : []
project_ids = contact.project_ids + add_project_ids - delete_project_ids
params[:contact][:project_ids] = project_ids.uniq if project_ids.any?
contact.tags.clear
contact.safe_attributes = parse_params_for_bulk_contact_attributes(params)
unless contact.save
# Keep unsaved issue ids to display them in flash error
unsaved_contact_ids << contact.id
end
if !params[:note][:content].blank?
note = ContactNote.new
note.safe_attributes = params[:note]
note.author = User.current
contact.notes << note
end
end
set_flash_from_bulk_contact_save(@contacts, unsaved_contact_ids)
redirect_back_or_default({ :controller => 'contacts', :action => 'index', :project_id => @project })
end
def edit_mails
@contacts = Contact.visible.where(:id => params[:ids]).reject { |c| c.email.blank? }
raise ActiveRecord::RecordNotFound if @contacts.empty?
if !@contacts.collect { |c| c.send_mail_allowed? }.inject { |memo, d| memo && d }
deny_access
return
end
end
def send_mails
contacts = Contact.visible.where(:id => params[:ids])
raise ActiveRecord::RecordNotFound if contacts.empty?
if !contacts.collect { |c| c.send_mail_allowed? }.inject { |memo, d| memo && d }
deny_access
return
end
raise_delivery_errors = ActionMailer::Base.raise_delivery_errors
# Force ActionMailer to raise delivery errors so we can catch it
ActionMailer::Base.raise_delivery_errors = true
delivered_contacts = []
error_contacts = []
contacts.each do |contact|
begin
params[:message] = mail_macro(contact, params[:"message-content"])
ContactsMailer.bulk_mail(contact, params).deliver
delivered_contacts << contact
note = ContactNote.new
note.subject = params[:subject]
note.content = params[:message]
note.author = User.current
note.type_id = Note.note_types[:email]
contact.notes << note
Attachment.attach_files(note, params[:attachments])
render_attachment_warning_if_needed(note)
rescue Exception => e
error_contacts << [contact, e.message]
end
flash[:notice] = l(:notice_email_sent, delivered_contacts.map { |c| "#{c.name} <span class='icon icon-email'>#{c.emails.first}</span>" }.join(', ')).chomp[0, 500] if delivered_contacts.any?
flash[:error] = l(:notice_email_error, error_contacts.map { |e| "#{e[0].name}: #{e[1]}"}.join(', ')).chomp[0, 500] if error_contacts.any?
end
ActionMailer::Base.raise_delivery_errors = raise_delivery_errors
redirect_back_or_default({:controller => 'contacts', :action => 'index', :project_id => @project})
end
def preview_email
@text = mail_macro(Contact.visible.where(:id => params[:ids][0]).first, params[:"message-content"])
render :partial => 'common/preview'
end
def load_tab
end
private
def find_contact_issues
scope = @contact.issues
scope = scope.open unless RedmineContacts.settings[:show_closed_issues]
@contact_issues_count = scope.visible.count
@contact_issues = scope.visible.order("#{Issue.table_name}.status_id, #{Issue.table_name}.updated_on DESC").limit(10)
end
def remove_old_avatars
params_hash = params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params
avatar_params = params_hash[:attachments].find { |_k, v| v['description'] == 'avatar' }.try(:last) if params_hash[:attachments].present?
return unless avatar_params
avatar_id = avatar_params['token'].split('.').first.to_i
@contact.attachments.where(:description => 'avatar').where('id != ?', avatar_id).destroy_all if @contact.avatar
end
def last_notes(count = 5)
scope = ContactNote.where({})
scope = scope.where("#{Project.table_name}.id = ?", @project.id) if @project
scope = scope.includes(:attachments)
@last_notes = scope.visible.
limit(count).
order("#{ContactNote.table_name}.created_on DESC").uniq
end
def find_contact
@contact = Contact.find(params[:id])
unless @contact.visible?
deny_access
return
end
project_id = (params[:contact] && params[:contact][:project_id]) || params[:project_id]
@project = Project.find_by_identifier(project_id)
@project ||= @contact.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_deals
scope = Deal.where({})
scope = scope.where("#{Deal.table_name}.project_id = ?", @project.id) if @project
scope = scope.where("#{Deal.table_name}.name LIKE ? ", '%' + params[:search] + '%') if params[:search]
scope = scope.where('1=0') if params[:tag]
@deals = scope.visible
end
def parse_params_for_bulk_contact_attributes(params)
attributes = (params[:contact] || {}).reject { |_k, v| v.blank? }
attributes.each { |k, v| attributes[k] = v.reject { |_key, val| val.blank? } if v.is_a?(Hash) }
attributes.keys.each { |k| attributes[k] = '' if attributes[k] == 'none' }
if custom = attributes[:custom_field_values]
custom.reject! { |_k, v| v.blank? }
custom.keys.each do |k|
if custom[k].is_a?(Array)
custom[k] << '' if custom[k].delete('__none__')
else
custom[k] = '' if custom[k] == '__none__'
end
end
attributes[:custom_field_values] = custom
end
attributes
end
def find_contacts(pages = true)
@tag = RedmineCrm::TagList.from(params[:tag]) unless params[:tag].blank?
scope = Contact.where({})
scope = scope.where("#{Contact.table_name}.job_title = ?", params[:job_title]) unless params[:job_title].blank?
scope = scope.where("#{Contact.table_name}.assigned_to_id = ?", params[:assigned_to_id]) unless params[:assigned_to_id].blank?
scope = scope.where("#{Contact.table_name}.is_company = ?", params[:query]) unless (params[:query].blank? || params[:query] == '2' || params[:query] == '3')
scope = scope.where("#{Contact.table_name}.author_id = ?", User.current) if params[:query] == '3'
case params[:query]
when '2' then scope = scope.order_by_creation
when '3' then scope = scope.order_by_creation
else scope = scope.order_by_name
end
scope = scope.by_project(@project)
params[:search].split(' ').collect{ |search_string| scope = scope.live_search(search_string) } if !params[:search].blank?
scope = scope.visible
scope = scope.tagged_with(params[:tag]) if !params[:tag].blank?
scope = scope.tagged_with(params[:notag], :exclude => true) if !params[:notag].blank?
@contacts_count = scope.count
@contacts = scope
if pages
page_size = params[:page_size].blank? ? 20 : params[:page_size].to_i
@contacts_pages = Paginator.new(self, @contacts_count, page_size, params[:page])
@offset = @contacts_pages.offset
@limit = @contacts_pages.items_per_page
@contacts = @contacts.eager_load([:tags, :avatar]).limit(@limit).offset(@offset)
fake_name = @contacts.first.name if @contacts.length > 0
end
@contacts
end
# Filter for bulk issue operations
def bulk_find_contacts
@contacts = Deal.find_all_by_id(params[:id] || params[:ids], :include => :project)
raise ActiveRecord::RecordNotFound if @contact.empty?
if @contacts.detect { |contact| !contact.visible? }
deny_access
return
end
@projects = @contacts.collect(&:projects).compact.uniq
@project = @projects.first if @projects.size == 1
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project(project_id = nil)
project_id ||= (params[:contact] && params[:contact][:project_id]) || params[:project_id]
@project = Project.find(project_id)
rescue ActiveRecord::RecordNotFound
render_404
end
def authorize_contacts(action = params[:action], _global = false)
case action.to_s
when 'edit', 'update'
@contact.editable? ? true : deny_access
when 'destroy'
@contact.deletable? ? true : deny_access
else
deny_access
end
end
def redirect_on_create(options)
if options[:redirect_on_success].to_s.match('^(http|https):\/\/')
redirect_to options[:redirect_on_success].to_s
else
render :action => 'show', :status => :created, :location => contact_url(@contact)
end
end
end
@@ -0,0 +1,102 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsDuplicatesController < ApplicationController
unloadable
helper :contacts
before_action :find_project_by_project_id, :authorize, :except => :search
before_action :find_contact, :except => :duplicates
before_action :find_duplicate, :only => :merge
helper :contacts
def index
@contacts = @contact.duplicates
end
def duplicates
search_first_name = params[:contact][:first_name] if params[:contact] && !params[:contact][:first_name].blank?
search_last_name = params[:contact][:last_name] if params[:contact] && !params[:contact][:last_name].blank?
search_middle_name = params[:contact][:middle_name] if params[:contact] && !params[:contact][:middle_name].blank?
@contact = (Contact.find(params[:contact_id]) if !params[:contact_id].blank?) || Contact.new
@contact.first_name = search_first_name || ''
@contact.last_name = search_last_name || ''
@contact.middle_name = search_middle_name || ''
respond_to do |format|
format.html { render :partial => 'duplicates', :layout => false if request.xhr? }
end
end
def merge
@duplicate.notes << @contact.notes
@duplicate.deals << @contact.deals
@duplicate.related_deals << @contact.related_deals
@duplicate.issues << @contact.issues
@duplicate.projects << @contact.projects
@duplicate.email = (@duplicate.emails | @contact.emails).join(', ')
@duplicate.phone = (@duplicate.phones | @contact.phones).join(', ')
call_hook(:controller_contacts_duplicates_merge, { :params => params, :duplicate => @duplicate, :contact => @contact })
@duplicate.tag_list = @duplicate.tag_list | @contact.tag_list
begin
Contact.transaction do
@duplicate.save!
@duplicate.reload
@contact.reload
@contact.destroy
flash[:notice] = l(:notice_successful_merged)
redirect_to :controller => 'contacts', :action => 'show', :project_id => @project, :id => @duplicate
end
rescue
redirect_to :action => 'duplicates', :contact_id => @contact, :project_id => @project
end
end
def search
@contacts = []
q = (params[:q] || params[:term]).to_s.strip
if q.present?
scope = Contact.where({})
scope = scope.limit(params[:limit] || 10)
scope = scope.companies if params[:is_company]
scope = scope.where(["#{Contact.table_name}.id <> ?", params[:contact_id].to_i]) if params[:contact_id]
@contacts = scope.visible.by_project(@project).live_search(q).to_a.sort!{|x, y| x.name <=> y.name }
else
@contacts = @contact.duplicates
end
render :layout => false, :partial => 'list'
end
private
def find_duplicate
@duplicate = Contact.find(params[:duplicate_id])
render_403 unless @duplicate.editable?
rescue ActiveRecord::RecordNotFound
render_404
end
def find_contact
@contact = Contact.find(params[:contact_id])
rescue ActiveRecord::RecordNotFound
render_404 if !request.xhr?
end
end
@@ -0,0 +1,109 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsIssuesController < ApplicationController
unloadable
before_action :find_contact, :only => [:create_issue, :delete]
before_action :find_issue, :except => [:create_issue]
before_action :find_project_by_project_id, :only => [:create_issue]
before_action :authorize_global, :only => [:close]
before_action :authorize
helper :contacts
def create_issue
deny_access unless User.current.allowed_to?(:manage_contact_issue_relations, @project) || User.current.allowed_to?(:add_issues, @project)
issue = Issue.new
issue.project = @project
issue.author = User.current
issue.status = IssueStatus.default if ActiveRecord::VERSION::MAJOR < 4
issue.start_date ||= Date.today
issue.contacts << @contact
issue.safe_attributes = params[:issue] if params[:issue]
if issue.save
flash[:notice] = l(:notice_successful_add)
else
flash[:error] = issue.errors.full_messages.join('<br>').html_safe
end
redirect_to :back
end
def create
contact_ids = []
if params[:contacts_issue].present?
contact_ids << (params[:contacts_issue][:contact_ids] || params[:contacts_issue][:contact_id])
else
contact_ids << params[:contact_id]
end
contact_ids.flatten.compact.uniq.each do |contact_id|
ContactsIssue.create(:issue_id => @issue.id, :contact_id => contact_id)
end
respond_to do |format|
format.html { redirect_to_referer_or { render :text => 'Added.', :layout => true } }
format.js
end
end
def new
end
def delete
@issue.contacts.delete(@contact)
respond_to do |format|
format.html { redirect_to :back }
format.js
end
end
def close
@issue.init_journal(User.current)
@issue.status = IssueStatus.where(:is_closed => true).first
@issue.save
respond_to do |format|
format.js
format.html { redirect_to :back }
end
end
def autocomplete_for_contact
q = params[:q].to_s
scope = Contact.where({})
q.split(' ').collect { |search_string| scope = scope.live_search(search_string) } unless q.blank?
@contacts = scope.visible.includes(:avatar).order(Contact.fields_for_order_statement).by_project(params[:cross_project_contacts] == '1' ? nil : @project).limit(100)
@contacts -= @issue.contacts if @issue
render :layout => false
end
private
def find_contact
@contact = Contact.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_issue
@issue = Issue.find(params[:issue_id])
@project = @issue.project
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,38 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsMailerController < ActionController::Base
before_action :check_credential
helper :contacts
# Submits an incoming email to ContactsMailer
def index
options = params.dup
email = options.delete(:email)
head ContactsMailer.receive(email, options) ? :created : :unprocessable_entity
end
private
def check_credential
User.current = nil
unless Setting.mail_handler_api_enabled? && params[:key].to_s == Setting.mail_handler_api_key
render :text => 'Access denied. Incoming emails WS is disabled or key is invalid.', :status => 403
end
end
end
@@ -0,0 +1,87 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsProjectsController < ApplicationController
unloadable
before_action :find_optional_project, :find_contact
before_action :find_related_project, :only => [:destroy, :create]
before_action :check_count, :only => :destroy
accept_api_auth :create, :destroy
helper :contacts
def new
@show_form = "true"
respond_to do |format|
format.html { redirect_to :back }
format.js
end
rescue ::ActionController::RedirectBackError
render :text => 'Project added.', :layout => true
end
def create
@contact.projects << @related_project
if @contact.save
respond_to do |format|
format.html { redirect_to :back }
format.js { render :action => "new" }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { redirect_to :back }
format.js { render :action => "new" }
format.api { render_validation_errors(@contact) }
end
end
end
def destroy
@contact.projects.delete(@related_project)
respond_to do |format|
format.html { redirect_to :back }
format.js {render :action => "new"}
format.api { render_api_ok }
end
end
private
def find_related_project
@related_project = Project.find((params[:project] && params[:project][:id]) || params[:id])
raise Unauthorized unless User.current.allowed_to?(:edit_contacts, @related_project)
rescue ActiveRecord::RecordNotFound
render_404
end
def check_count
deny_access if @contact.projects.size <= 1
end
def find_contact
@contact = Contact.find(params[:contact_id])
raise Unauthorized unless @contact.editable?
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,34 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsSettingsController < ApplicationController
unloadable
before_action :find_project_by_project_id, :authorize
def save
settings = params[:contacts_settings]
settings = settings.to_unsafe_hash if settings.class.to_s == 'ActionController::Parameters'
if settings && settings.is_a?(Hash)
settings.map do |k, v|
ContactsSetting[k, @project.id] = v
end
end
redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => params[:tab]
end
end
@@ -0,0 +1,98 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsTagsController < ApplicationController
unloadable
before_action :require_admin, :except => [:index]
before_action :find_tag, :only => [:edit, :update]
before_action :bulk_find_tags, :only => [:context_menu, :merge, :destroy]
accept_api_auth :index
def index
@tags = Contact.all_tag_counts(:order => :name)
respond_to do |format|
format.api
end
end
def edit
end
def destroy
@tags.each do |tag|
begin
tag.reload.destroy
Contact.where("#{Contact.table_name}.cached_tag_list LIKE ?", '%' + tag.name + '%').includes(:tags).each{|c| c.tag_list = c.all_tags_list; c.save}
rescue ::ActiveRecord::RecordNotFound # raised by #reload if tag no longer exists
# nothing to do, tag was already deleted (eg. by a parent)
end
end
redirect_back_or_default(:controller => 'settings', :action => 'plugin', :id => 'redmine_contacts', :tab => "tags")
end
def update
old_name = @tag.name
@tag.name = params[:tag][:name]
if @tag.save
Contact.where("#{Contact.table_name}.cached_tag_list LIKE ?", '%' + old_name + '%').includes(:tags).each{|c| c.tag_list = c.all_tags_list; c.save}
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_to :controller => 'settings', :action => 'plugin', :id => 'redmine_contacts', :tab => "tags" }
end
else
respond_to do |format|
format.html { render :action => "edit"}
end
end
end
def context_menu
@tag = @tags.first if (@tags.size == 1)
@back = back_url
render :layout => false
end
def merge
if request.post? && params[:tag] && params[:tag][:name]
RedmineCrm::Tagging.transaction do
tag = RedmineCrm::Tag.where(:name => params[:tag][:name]).first || RedmineCrm::Tag.create(params[:tag])
RedmineCrm::Tagging.where(:tag_id => @tags.map(&:id)).update_all(:tag_id => tag.id)
@tags.select{|t| t.id != tag.id}.each do |t|
t.destroy
Contact.where("#{Contact.table_name}.cached_tag_list LIKE ?", '%' + t.name + '%').includes(:tags).each{|c| c.tag_list = c.all_tags_list; c.save}
end
redirect_to :controller => 'settings', :action => 'plugin', :id => 'redmine_contacts', :tab => "tags"
end
end
end
private
def bulk_find_tags
@tags = RedmineCrm::Tag.where(:id => params[:id] ? [params[:id]] : params[:ids])
raise ActiveRecord::RecordNotFound if @tags.empty?
end
def find_tag
@tag = RedmineCrm::Tag.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,97 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsVcfController < ApplicationController
unloadable
before_action :find_project_by_project_id, :authorize
def load
begin
vcard = Vcard::Vcard.decode(params[:contact_vcf]).first
contact = {}
fill_name(vcard, contact)
contact[:phone] = vcard.telephones.join(', ')
contact[:email] = vcard.emails.join(', ')
contact[:website] = vcard.url.uri if vcard.url
contact[:birthday] = vcard.birthday
fill_background(vcard, contact)
fill_title(vcard, contact)
fill_address(vcard, contact) if vcard['ADR']
fill_company(vcard, contact) if vcard.org
respond_to do |format|
format.html { redirect_to :controller => 'contacts', :action => 'new', :project_id => @project, :contact => contact }
end
rescue Exception => e
flash[:error] = e.message
respond_to do |format|
format.html { redirect_to :back }
end
end
end
private
def fill_name(vcard, contact)
vcard_charset = get_field_encoding(vcard, 'N')
contact[:first_name] = encode(vcard_charset, vcard.name.given)
contact[:middle_name] = encode(vcard_charset, vcard.name.additional)
contact[:last_name] = encode(vcard_charset, vcard.name.family)
end
def fill_address(vcard, contact)
vcard_charset = get_field_encoding(vcard, 'ADR')
contact[:address_attributes] = {}
contact[:address_attributes][:street1] = encode(vcard_charset, vcard.address.street)
contact[:address_attributes][:city] = encode(vcard_charset, vcard.address.locality)
contact[:address_attributes][:postcode] = encode(vcard_charset, vcard.address.postalcode)
contact[:address_attributes][:region] = encode(vcard_charset, vcard.address.region)
end
def fill_background(vcard, contact)
vcard_charset = get_field_encoding(vcard, 'NOTE')
contact[:background] = encode(vcard_charset, vcard.note)
end
def fill_company(vcard, contact)
vcard_charset = get_field_encoding(vcard, 'ORG')
contact[:company] = encode(vcard_charset, vcard.org.first)
end
def fill_title(vcard, contact)
vcard_charset = get_field_encoding(vcard, 'TITLE')
contact[:job_title] = encode(vcard_charset, vcard.title)
end
def get_field_encoding(vcard, field_name)
vcard.fields.find { |field| field.name == field_name }.try(:pvalue, 'CHARSET')
end
def encode(vcard_charset, field)
return field if vcard_charset.nil?
if RUBY_VERSION < '1.9'
Iconv.conv('UTF-8', vcard_charset, field)
else
field.force_encoding(vcard_charset).encode('UTF-8')
end
end
end
@@ -0,0 +1,133 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class CrmQueriesController < ApplicationController
before_action :find_query_class
before_action :find_query, :except => [:new, :create, :index]
before_action :find_optional_project, :only => [:new, :create]
before_action :set_menu_item
accept_api_auth :index
helper :queries
include QueriesHelper
def index
case params[:format]
when 'xml', 'json'
@offset, @limit = api_offset_and_limit
else
@limit = per_page_option
end
@query_count = @query_class.visible.count
@query_pages = Paginator.new @query_count, @limit, params['page']
@queries = @query_class.visible.
order("#{Query.table_name}.name").
limit(@limit).
offset(@offset).
all
respond_to do |format|
format.api
end
end
def new
@query = @query_class.new
@query.user = User.current
@query.project = @project
@query.visibility = @query_class::VISIBILITY_PRIVATE unless User.current.allowed_to?("manage_public_#{@object_type}s_queries".to_sym, @project) || User.current.admin?
@query.build_from_params(params)
end
def create
@query = @query_class.new(params_hash[:query])
@query.user = User.current
@query.project = params_hash[:query_is_for_all] ? nil : @project
@query.visibility = @query_class::VISIBILITY_PRIVATE unless User.current.allowed_to?("manage_public_#{@object_type}s_queries".to_sym, @project) || User.current.admin?
@query.build_from_params(params_hash)
@query.column_names = nil if params_hash[:default_columns]
if @query.save
flash[:notice] = l(:notice_successful_create)
redirect_to_list(:query_id => @query)
else
render :action => 'new', :layout => !request.xhr?
end
end
def edit
end
def update
@query.attributes = params_hash[:query]
@query.project = nil if params_hash[:query_is_for_all]
@query.visibility = @query_class::VISIBILITY_PRIVATE unless User.current.allowed_to?("manage_public_#{@object_type}s_queries".to_sym, @project) || User.current.admin?
@query.build_from_params(params_hash)
@query.column_names = nil if params_hash[:default_columns]
if @query.save
flash[:notice] = l(:notice_successful_update)
redirect_to_list(:query_id => @query)
else
render :action => 'edit'
end
end
def destroy
@query.destroy
redirect_to_list(:set_filter => 1)
end
private
def find_query_class
raise NameError if params[:object_type].blank?
@query_class = Object.const_get("#{params[:object_type].to_s.camelcase}Query")
@object_type = params[:object_type]
return false unless @query_class.is_a?(Query)
rescue NameError
render_404
end
def find_query
@query = @query_class.find(params[:id])
@project = @query.project
render_403 unless @query.editable_by?(User.current)
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_project
@project = Project.find(params[:project_id]) if params[:project_id]
render_403 unless User.current.allowed_to?("save_#{@object_type}s_queries".to_sym, @project, :global => true)
rescue ActiveRecord::RecordNotFound
render_404
end
def redirect_to_list(options)
redirect_to url_for({:controller => "#{@object_type}s", :action => "index", :project_id => @project}.merge(options))
end
def set_menu_item
menu_items[:project_tabs][:actions][action_name.to_sym] = "#{@object_type}s"
end
def params_hash
@params_hash ||= params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash.symbolize_keys : params
end
end
@@ -0,0 +1,109 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealCategoriesController < ApplicationController
unloadable
menu_item :settings
model_object DealCategory
before_action :find_model_object, :except => [:new, :index, :create]
before_action :find_project_from_association, :except => [:new, :index, :create]
before_action :find_project_by_project_id, :only => [:new, :index, :create]
before_action :authorize
accept_api_auth :index, :update, :create, :destroy
def index
@categories = @project.deal_categories
respond_to do |format|
format.api
end
end
def create
@category = @project.deal_categories.build
@category.safe_attributes = params[:category]
if @category.save
flash[:notice] = l(:notice_successful_create)
respond_to do |format|
format.html { redirect_to_settings_in_projects }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { render :action => 'new' }
format.api { render_validation_errors(@category) }
end
end
end
def new
@category = @project.deal_categories.build(params[:category])
end
def edit
end
def update
@category.safe_attributes = params[:category]
if @category.save
# @deal.contacts = [Contact.find(params[:contacts])] if params[:contacts]
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_to_settings_in_projects }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { render :action => 'edit' }
format.api { render_validation_errors(@category) }
end
end
end
def destroy
@deal_count = @category.deals.size
if @deal_count == 0 || params[:todo] || api_request?
reassign_to = nil
if params[:reassign_to_id] && (params[:todo] == 'reassign' || params[:todo].blank?)
reassign_to = @project.deal_categories.find_by_id(params[:reassign_to_id])
end
@category.destroy(reassign_to)
respond_to do |format|
format.html { redirect_to_settings_in_projects }
format.api { render_api_ok }
end
return
end
@categories = @project.deal_categories - [@category]
end
private
def redirect_to_settings_in_projects
redirect_to settings_project_path(@project, :tab => 'deals')
end
# Wrap ApplicationController's find_model_object method to set
# @category instead of just @deal_category
def find_model_object
super
@category = @object
@project = @category.project
end
end
@@ -0,0 +1,81 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealContactsController < ApplicationController
unloadable
before_action :find_project_by_project_id, :authorize
before_action :find_contact, :only => :delete
before_action :find_deal
helper :deals
helper :contacts
def search
@contacts = contacts.limit(10) - @deal.all_contacts
end
def autocomplete
@contacts = contacts.live_search(params[:q]).limit(100) - @deal.all_contacts
render :layout => false
end
def add
if params[:contact_id] && request.post?
find_contact
unless @deal.all_contacts.include?(@contact)
@deal.related_contacts << @contact
@deal.save
end
end
respond_to do |format|
format.html do
redirect_to :back
end
format.js
end
end
def delete
@deal.related_contacts.delete(@contact)
respond_to do |format|
format.html { redirect_to :back }
format.js
end
end
private
def contacts
Contact.visible.by_project(ContactsSetting.cross_project_contacts? ? nil : @project)
end
def find_contact
@contact = Contact.find(params[:contact_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_deal
@deal = Deal.find(params[:deal_id])
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,36 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealImportsController < ImporterBaseController
menu_item :deals
helper :deals
def klass
DealImport
end
def importer_klass
DealKernelImport
end
def instance_index
project_deals_path(:project_id => @project.id)
end
end
@@ -0,0 +1,95 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealStatusesController < ApplicationController
unloadable
layout 'admin'
before_action :require_admin, :except => :assing_to_project
before_action :find_project_by_project_id, :authorize, :only => :assing_to_project
accept_api_auth :index
def index
@deal_statuses = DealStatus.order(:position)
respond_to do |format|
format.api
format.html { render :action => 'index', :layout => false if request.xhr? }
end
end
def new
@deal_status = DealStatus.new
end
def create
@deal_status = DealStatus.new
@deal_status.safe_attributes = params[:deal_status]
if @deal_status.save
flash[:notice] = l(:notice_successful_create)
redirect_to :action => 'plugin', :id => 'redmine_contacts', :controller => 'settings', :tab => 'deal_statuses'
else
render :action => 'new'
end
end
def edit
@deal_status = DealStatus.find(params[:id])
end
def update
@deal_status = DealStatus.find(params[:id])
@deal_status.safe_attributes = params[:deal_status]
@deal_status.insert_at(@deal_status.position) if @deal_status.position_changed?
if @deal_status.save
respond_to do |format|
format.html do
flash[:notice] = l(:notice_successful_update)
redirect_to :action => 'plugin', :id => 'redmine_contacts', :controller => 'settings', :tab => 'deal_statuses'
end
format.js { head 200 }
end
else
respond_to do |format|
format.html do
render :action => 'edit'
end
format.js { head 422 }
end
end
end
def destroy
DealStatus.find(params[:id]).destroy
redirect_to :action => 'plugin', :id => 'redmine_contacts', :controller => 'settings', :tab => 'deal_statuses'
rescue
flash[:error] = l(:error_unable_delete_deal_status)
redirect_to :action => 'plugin', :id => 'redmine_contacts', :controller => 'settings', :tab => 'deal_statuses'
end
def assing_to_project
if request.put?
@project.deal_statuses = !params[:deal_statuses].blank? ? DealStatus.find(params[:deal_statuses]) : []
@project.save
flash[:notice] = l(:notice_successful_update)
end
redirect_to :controller => 'projects', :action => 'settings', :tab => 'deals', :id => @project
end
end
@@ -0,0 +1,333 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealsController < ApplicationController
unloadable
PRICE_TYPE_PULLDOWN = [l(:label_price_fixed_bid), l(:label_price_per_hour)]
before_action :find_deal, :only => [:show, :edit, :update, :destroy]
before_action :find_project, :only => [:new, :create, :update_form]
before_action :bulk_find_deals, :only => [:bulk_update, :bulk_edit, :bulk_destroy, :context_menu]
before_action :authorize, :except => [:index]
before_action :find_optional_project, :only => [:index]
before_action :update_deal_from_params, :only => [:edit, :update]
before_action :build_new_deal_from_params, :only => [:new, :update_form]
before_action :find_deal_attachments, :only => :show
skip_before_filter :authorize, :only => :add_product_line if RedmineContacts.products_plugin_installed?
accept_api_auth :index, :show, :create, :update, :destroy
helper :attachments
helper :timelog
helper :watchers
helper :custom_fields
helper :context_menus
helper :sort
helper :crm_queries
helper :notes
helper :queries
helper :calendars
include QueriesHelper
include CrmQueriesHelper
include WatchersHelper
include DealsHelper
include SortHelper
if RedmineContacts.products_plugin_installed?
include ProductsHelper
helper :products
end
def index
retrieve_crm_query('deal')
sort_init(@query.sort_criteria.empty? ? [['created_on', 'desc']] : @query.sort_criteria)
sort_update(@query.sortable_columns)
@query.sort_criteria = sort_criteria.to_a
if @query.valid?
case params[:format]
when 'csv', 'pdf'
@limit = Setting.issues_export_limit.to_i
when 'atom'
@limit = Setting.feeds_limit.to_i
when 'xml', 'json'
@offset, @limit = api_offset_and_limit
else
@limit = per_page_option
end
@deals_count = @query.object_count
@deals_scope = @query.objects_scope
@deal_amount = @query.deal_amount
@deal_weighted_amount = @query.weighted_amount
@deals_pages = Paginator.new @deals_count, @limit, params['page']
@offset ||= @deals_pages.offset
@deal_count_by_group = @query.object_count_by_group
@deals = @query.results_scope(
:include => [{ :contact => [:avatar, :projects, :address] }, :author],
:search => params[:search],
:order => sort_clause,
:limit => @limit,
:offset => @offset
)
if deals_list_style == 'crm_calendars/crm_calendar'
retrieve_crm_calendar(:start_date_field => 'due_date')
@calendar.events = @query.results_scope(
:include => [:contact],
:search => params[:search],
:conditions => ['due_date BETWEEN ? AND ?', @calendar.startdt, @calendar.enddt]
)
end
respond_to do |format|
format.html { request.xhr? ? render(:partial => deals_list_style, :layout => false) : last_notes }
format.api
format.atom { render_feed(@deals, :title => "#{@project || Setting.app_title}: #{l(:label_order_plural)}") }
format.csv { send_data(deals_to_csv(@deals), :type => 'text/csv; header=present', :filename => 'deals.csv') }
format.pdf { send_data(deals_to_pdf(@deals, @project, @query), :type => 'application/pdf', :filename => 'deals.pdf') }
end
else
respond_to do |format|
format.html { render(:template => 'deals/index', :layout => !request.xhr?) }
format.any(:atom, :csv, :pdf) { render(:nothing => true) }
format.api { render_validation_errors(@query) }
end
end
rescue ActiveRecord::RecordNotFound
render_404
end
def show
@note = DealNote.new(:created_on => Time.now)
respond_to do |format|
format.html do
@deal_issues = @deal.issues.visible
@deal.viewed
@deal_events = (@deal.deal_processes.where("#{DealProcess.table_name}.old_value IS NOT NULL").includes([:to, :from, :author]) | @deal.notes.includes([:attachments, :author])).map{|o| {:date => o.is_a?(DealProcess) ? o.created_at : o.created_on, :author => o.author, :object => o} }
@deal_events.sort! { |x, y| y[:date] <=> x[:date] }
end
format.api
end
end
def new
end
def create
@deal = Deal.new
@deal.safe_attributes = params[:deal]
@deal.project = @project
@deal.author ||= User.current
@deal.price = parsed_price(params[:deal][:price])
@deal.init_deal_process(User.current)
if @deal.save
flash[:notice] = l(:notice_successful_create)
respond_to do |format|
format.html { redirect_to(params[:continue] ? { :action => 'new' } : { :action => 'show', :id => @deal }) }
format.api { render :action => 'show', :status => :created, :location => deal_url(@deal) }
end
else
respond_to do |format|
format.html { render :action => 'new' }
format.api { render_validation_errors(@deal) }
end
end
end
def update
@deal.init_deal_process(User.current)
@deal.safe_attributes = params[:deal]
if @deal.save
# @deal.contacts = [Contact.find(params[:contacts])] if params[:contacts]
retrieve_crm_query('deal')
@deals_scope = @query.objects_scope
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_back_or_default(:action => 'show', :id => @deal) }
format.api { render_api_ok }
format.js { render :update_total }
end
else
respond_to do |format|
format.html { render :action => 'edit' }
format.api { render_validation_errors(@deal) }
format.js { render "alert('Error!')" }
end
end
end
def edit
respond_to do |format|
format.html {}
format.xml {}
end
end
def destroy
if @deal.destroy
flash[:notice] = l(:notice_successful_delete)
respond_to do |format|
format.html { redirect_to :action => 'index', :project_id => params[:project_id] }
format.api { render_api_ok }
end
else
flash[:error] = l(:notice_unsuccessful_save)
end
end
def context_menu
@deal = @deals.first if @deals.size == 1
@can = { :edit => User.current.allowed_to?(:edit_deals, @projects),
:delete => User.current.allowed_to?(:delete_deals, @projects) }
@back = back_url
render :layout => false
end
def bulk_destroy
@deals.each do |deal|
begin
deal.reload.destroy
rescue ::ActiveRecord::RecordNotFound # raised by #reload if deal no longer exists
# nothing to do, deal was already deleted (eg. by a parent)
end
end
respond_to do |format|
format.html { redirect_back_or_default(:action => 'index', :project_id => params[:project_id]) }
format.api { head :ok }
end
end
def bulk_edit
@available_statuses = @projects.map(&:deal_statuses).inject { |memo, w| memo & w }
@custom_fields = DealCustomField.order(:name)
@available_categories = @projects.map(&:deal_categories).inject { |memo, w| memo & w }
@assignables = @projects.map(&:assignable_users).inject { |memo, a| memo & a }
end
def bulk_update
unsaved_deal_ids = []
@deals.each do |deal|
deal.reload
deal.init_deal_process(User.current)
deal.safe_attributes = parse_params_for_bulk_deal_attributes(params)
unless deal.save
# Keep unsaved deal ids to display them in flash error
unsaved_deal_ids << deal.id
end
if params[:note] && !params[:note][:content].blank?
note = DealNote.new
note.safe_attributes = params[:note]
note.author = User.current
deal.notes << note
end
end
set_flash_from_bulk_contact_save(@deals, unsaved_deal_ids)
redirect_back_or_default(:controller => 'deals', :action => 'index', :project_id => @project)
end
private
def last_notes(count = 5)
# TODO: Исправить говнокод этот и выделить все в плагин acts-as-noteble
scope = DealNote.where({})
scope = scope.where("#{Deal.table_name}.project_id = ?", @project.id) if @project
@last_notes = scope.visible.order("#{DealNote.table_name}.created_on DESC").limit(count)
end
def build_new_deal_from_params
if params[:id].blank?
@deal = Deal.new
@deal.assigned_to_id = User.current.id
@deal.name = params[:name] if params[:name]
@deal.contact = Contact.find(params[:contact_id]) if params[:contact_id]
if params[:copy_from]
begin
@copy_from = Deal.visible.find(params[:copy_from])
@deal.copy_from(@copy_from)
rescue ActiveRecord::RecordNotFound
render_404
return
end
end
else
@deal = Deal.visible.find(params[:id])
end
@deal.project = @project
@deal.author ||= User.current
@deal.safe_attributes = params[:deal]
@available_watchers = (@deal.project.users.sort + @deal.watcher_users).uniq
end
def update_deal_from_params
end
def update_form
end
def find_deal_attachments
@deal_attachments = Attachment.where(:container_type => 'Note', :container_id => @deal.notes.map(&:id)).order(:created_on)
end
def bulk_find_deals
@deals = Deal.where(:id => (params[:id] || params[:ids])).includes([:project, :contact])
raise ActiveRecord::RecordNotFound if @deals.empty?
if @deals.detect { |deal| !deal.visible? }
deny_access
return
end
@projects = @deals.collect(&:project).compact.uniq
@project = @projects.first if @projects.size == 1
rescue ActiveRecord::RecordNotFound
render_404
end
def find_deal
@deal = Deal.where(:id => params[:id]).includes([:project, :status, :category]).first
@project = @deal.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project(project_id = nil)
project_id ||= (params[:deal] && params[:deal][:project_id]) || params[:project_id]
@project = Project.find(project_id)
rescue ActiveRecord::RecordNotFound
render_404
end
def parse_params_for_bulk_deal_attributes(params)
attributes = (params[:deal] || {}).reject { |_k, v| v.blank? }
attributes.keys.each { |k| attributes[k] = '' if attributes[k] == 'none' }
attributes[:custom_field_values].reject! { |_k, v| v.blank? } if attributes[:custom_field_values]
attributes
end
def parsed_price(price)
return unless price
price.gsub!(ContactsSetting.thousands_delimiter, '')
price.gsub!(ContactsSetting.decimal_separator, '.')
price.to_f
end
end
@@ -0,0 +1,74 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealsTasksController < ApplicationController
unloadable
before_action :find_project_by_project_id, :authorize
before_action :find_deal, :except => [:close]
before_action :find_issue, :except => [:new]
def new
issue = Issue.new
issue.subject = params[:task_subject]
issue.project = @project
issue.tracker_id = params[:task_tracker]
issue.author = User.current
issue.due_date = params[:due_date]
issue.assigned_to_id = params[:assigned_to]
issue.description = params[:task_description]
issue.status = IssueStatus.default
if issue.save
flash[:notice] = l(:notice_successful_add)
@deal.issues << issue
@deal.save
redirect_to :back
return
else
redirect_to :back
end
end
def close
@issue.status = IssueStatus.find(:first, :conditions => { :is_closed => true })
@issue.save
respond_to do |format|
format.js do
render :update do |page|
page["issue_#{params[:issue_id]}"].visual_effect :fade
end
end
format.html {redirect_to :back }
end
end
private
def find_deal
@deal = Deal.find(params[:deal_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_issue
@issue = Issue.find(params[:issue_id])
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,149 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ImporterBaseController < ApplicationController
unloadable
if Redmine::VERSION.to_s >= '3.2'
helper :imports
before_action :find_import, :only => [:show, :settings, :mapping, :run]
end
before_action :find_project_by_project_id, :authorize
def new
@importer = klass.new
if Redmine::VERSION.to_s >= '3.2'
render 'importers/kernel_new'
else
render 'importers/new'
end
end
def create
if Redmine::VERSION.to_s >= '3.2'
@import = importer_klass.new
@import.user = User.current
@import.project = @project
@import.file = params[:file]
@import.set_default_settings
if @import.save
redirect_to :controller => klass.name.tableize, :action => 'settings', :id => @import, :project_id => @project
else
render 'importers/kernel_new'
end
else
@importer = klass.new(params[klass.to_s.underscore.to_sym])
@importer.project = @project
if @importer.file && @importer.save
redirect_to instance_index
else
render 'importers/new'
end
end
end
def show
render 'importers/show'
end
def settings
if request.post? && @import.parse_file
return redirect_to :controller => klass.name.tableize, :action => 'mapping', :id => @import, :project_id => @project
end
render 'importers/settings'
rescue CSV::MalformedCSVError => e
flash.now[:error] = l(:error_invalid_csv_file_or_settings)
render 'importers/settings'
rescue ArgumentError, Encoding::InvalidByteSequenceError => e
flash.now[:error] = l(:error_invalid_file_encoding, :encoding => ERB::Util.h(@import.settings['encoding']))
render 'importers/settings'
rescue SystemCallError => e
flash.now[:error] = l(:error_can_not_read_import_file)
render 'importers/settings'
end
def mapping
mapping_object = klass.new.klass.new
@attributes = mapping_object.safe_attribute_names
@custom_fields = mapping_object.custom_field_values.map(&:custom_field)
if request.post?
respond_to do |format|
format.html do
if params[:previous]
redirect_to :controller => klass.name.tableize, :action => 'settings', :id => @import, :project_id => @project
else
redirect_to :controller => klass.name.tableize, :action => 'run', :id => @import, :project_id => @project
end
end
end
else
render 'importers/mapping'
end
end
def run
if request.post?
@current = @import.run(
:max_items => max_items_per_request,
:max_time => 10.seconds
)
respond_to do |format|
format.html do
if @import.finished?
redirect_to :controller => klass.name.tableize, :action => 'show', :id => @import, :project_id => @project
else
redirect_to :controller => klass.name.tableize, :action => 'run', :id => @import, :project_id => @project
end
end
format.js { render 'importers/run' }
end
else
render 'importers/run'
end
end
private
def find_import
@import = Import.where(:user_id => User.current.id, :filename => params[:id]).first
if @import.nil?
render_404
return
elsif @import.finished? && action_name != 'show'
redirect_to new_project_contact_import_path(@import)
return
end
update_from_params if request.post?
end
def update_from_params
if params[:import_settings].present?
@import.settings ||= {}
@import.settings.merge!(params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash['import_settings'] : params['import_settings'])
@import.save!
end
end
def max_items_per_request
5
end
end
@@ -0,0 +1,130 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class NotesController < ApplicationController
unloadable
default_search_scope :notes
# before_filter :find_model_object
before_action :find_note, :only => [:show, :edit, :update, :destroy]
before_action :find_project, :only => :create
before_action :find_note_source, :only => :create
before_action :find_optional_project, :only => :show
accept_api_auth :show, :create, :update, :destroy
helper :attachments
helper :custom_fields
def show
(render_403; return false) unless @note.visible?
respond_to do |format|
format.html
format.api
end
end
def new
find_note_source
@note = Note.new
@note.source = @note_source
end
def edit
(render_403; return false) unless @note.editable_by?(User.current, @project)
end
def update
@note.safe_attributes = params[:note]
if @note.save
@note.note_time = params[:note][:note_time] if params[:note] && params[:note][:note_time]
attachments = Attachment.attach_files(@note, (params[:attachments] || (params[:note] && params[:note][:uploads])))
render_attachment_warning_if_needed(@note)
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_back_or_default({ :action => 'show', :project_id => @note.source.project, :id => @note }) }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { render :action => 'edit', :project_id => params[:project_id], :id => @note }
format.api { render_validation_errors(@note) }
end
end
end
def create
@note = Note.new
@note.safe_attributes = params[:note]
@note.source = @note_source
@note.note_time = params[:note][:note_time] if params[:note] && params[:note][:note_time]
@note.author = User.current
if @note.save
attachments = Attachment.attach_files(@note, (params[:attachments] || (params[:note] && params[:note][:uploads])))
render_attachment_warning_if_needed(@note)
flash[:notice] = l(:notice_successful_create)
respond_to do |format|
format.js
format.html { redirect_to :back }
format.api { render :action => 'show', :status => :created, :location => note_url(@note) }
end
else
respond_to do |format|
format.html { redirect_to :back }
format.api { render_validation_errors(@note) }
end
end
end
def destroy
(render_403; return false) unless @note.destroyable_by?(User.current, @project)
@note.destroy
respond_to do |format|
format.js
format.html { redirect_to :action => 'show', :project_id => @project, :id => @note.source }
format.api { render_api_ok }
end
# redirect_to :action => 'show', :project_id => @project, :id => @contact
end
private
def find_project(project_id = nil)
project_id ||= (params[:note] && params[:note][:project_id]) || params[:project_id]
@project = Project.find(project_id)
rescue ActiveRecord::RecordNotFound
render_404
end
def find_note
@note = Note.find(params[:id])
@project ||= @note.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_note_source
note_source_type = (params[:note] && params[:note][:source_type]) || params[:source_type]
note_source_id = (params[:note] && params[:note][:source_id]) || params[:source_id]
klass = Object.const_get(note_source_type.camelcase)
@note_source = klass.find(note_source_id)
end
end
@@ -0,0 +1,110 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class TasksController < ApplicationController
unloadable
before_action :find_project_by_project_id, :authorize, :except => [:index]
before_action :find_optional_project, :only => :index
before_action :find_taskable, :except => [:index, :add, :close]
before_action :find_issue, :except => [:index, :new]
def new
issue = Issue.new
issue.subject = params[:task_subject]
issue.project = @project
issue.tracker_id = params[:task_tracker]
issue.author = User.current
issue.due_date = params[:due_date]
issue.assigned_to_id = params[:assigned_to]
issue.description = params[:task_description]
issue.status = IssueStatus.default
if issue.save
flash[:notice] = l(:notice_successful_add)
@taskable.issues << issue
@taskable.save
redirect_to :back
return
else
redirect_to :back
end
end
def add
@show_form = 'true'
if params[:source_id] && params[:source_type] && request.post?
find_taskable
@taskable.issues << @issue
@taskable.save
end
taskable_name = @taskable.class.name.underscore
respond_to do |format|
format.html { redirect_to :back }
format.js do
render :update do |page|
page.replace_html "issue_#{taskable_name}s", :partial => "issues/#{taskable_name}s"
end
end
end
end
def delete
@issue.taskables.delete(@taskable)
taskable_name = @taskable.class.name.underscore
respond_to do |format|
format.html { redirect_to :back }
format.js do
render :update do |page|
page.replace_html "issue_#{taskable_name}s", :partial => "issues/#{taskable_name}s"
end
end
end
end
def close
@issue.status = IssueStatus.find(:first, :conditions => { :is_closed => true })
@issue.save
respond_to do |format|
format.js do
render :update do |page|
page["issue_#{params[:issue_id]}"].visual_effect :fade
end
end
format.html { redirect_to :back }
end
end
private
def find_taskable
klass = Object.const_get(params[:source_type].camelcase)
@taskable = klass.find(params[:source_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_issue
@issue = Issue.find(params[:issue_id])
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,269 @@
# encoding: utf-8
#
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module ContactsHelper
def contact_tabs(contact)
contact_tabs = []
contact_tabs << {:name => 'notes', :partial => 'contacts/notes', :label => l(:label_crm_note_plural)} if contact.visible?
contact_tabs << {:name => 'contacts', :partial => 'company_contacts', :label => l(:label_contact_plural) + (contact.company_contacts.visible.count > 0 ? " (#{contact.company_contacts.count})" : "")} if contact.is_company?
contact_tabs << {:name => 'deals', :partial => 'deals/related_deals', :label => l(:label_deal_plural) + (contact.all_visible_deals.size > 0 ? " (#{contact.all_visible_deals.size})" : "") } if User.current.allowed_to?(:add_deals, @project)
contact_tabs
end
def settings_contacts_tabs
ret = [
{:name => 'general', :partial => 'settings/contacts/contacts_general', :label => :label_general},
{:name => 'money', :partial => 'settings/contacts/money', :label => :label_crm_money_settings},
{:name => 'tags', :partial => 'settings/contacts/contacts_tags', :label => :label_crm_tags_plural},
{:name => 'deal_statuses', :partial => 'settings/contacts/contacts_deal_statuses', :label => :label_crm_deal_status_plural},
]
ret.push({:name => 'hidden', :partial => 'settings/contacts/contacts_hidden', :label => :label_crm_contacts_hidden}) if params[:hidden]
ret
end
def collection_for_visibility_select
[[l(:label_crm_contacts_visibility_project), Contact::VISIBILITY_PROJECT],
[l(:label_crm_contacts_visibility_public), Contact::VISIBILITY_PUBLIC],
[l(:label_crm_contacts_visibility_private), Contact::VISIBILITY_PRIVATE]]
end
def contact_list_styles_for_select
list_styles = [[l(:label_crm_list_excerpt), "list_excerpt"]]
list_styles += [[l(:label_crm_list_list), "list"],
[l(:label_crm_list_cards), "list_cards"]]
end
def contacts_list_style
list_styles = contact_list_styles_for_select.map(&:last)
if params[:contacts_list_style].blank?
list_style = list_styles.include?(session[:contacts_list_style]) ? session[:contacts_list_style] : RedmineContacts.default_list_style
else
list_style = list_styles.include?(params[:contacts_list_style]) ? params[:contacts_list_style] : RedmineContacts.default_list_style
end
session[:contacts_list_style] = list_style
end
def authorized_for_permission?(permission, project, global = false)
User.current.allowed_to?(permission, project, :global => global)
end
def render_contact_projects_hierarchy(projects)
s = ''
project_tree(projects) do |project, level|
s << "<ul>"
name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
s << "<li id='project_#{project.id}'>" + name_prefix + link_to_project(project)
s += ' ' + link_to(image_tag('delete.png'),
contact_contacts_project_path(@contact, :id => project.id, :project_id => @project.id),
:remote => true,
:method => :delete,
:style => "vertical-align: middle",
:class => "delete",
:title => l(:button_delete)) if (projects.size > 1 && User.current.allowed_to?(:edit_contacts, project))
s << "</li>"
s << "</ul>"
end
s.html_safe
end
def contact_to_vcard(contact)
return false unless ContactsSetting.vcard?
card = Vcard::Vcard::Maker.make2 do |maker|
maker.add_name do |name|
name.prefix = ''
name.given = contact.first_name.to_s
name.family = contact.last_name.to_s
name.additional = contact.middle_name.to_s
end
maker.add_addr do |addr|
addr.preferred = true
addr.street = contact.street1.to_s.gsub("\r\n"," ").gsub("\n"," ")
addr.locality = contact.city.to_s
addr.region = contact.region.to_s
addr.postalcode = contact.postcode.to_s
addr.country = contact.country.to_s
addr.location = 'business'
end
maker.title = contact.job_title.to_s
maker.org = contact.company.to_s
maker.birthday = contact.birthday.to_date unless contact.birthday.blank?
maker.add_note(contact.background.to_s.gsub("\r\n"," ").gsub("\n", ' '))
maker.add_url(contact.website.to_s)
contact.phones.each { |phone| maker.add_tel(phone) }
contact.emails.each { |email| maker.add_email(email) }
end
avatar = contact.attachments.find_by_description('avatar')
card = card.encode.sub("END:VCARD", "PHOTO;BASE64:" + "\n " + [File.open(avatar.diskfile).read].pack('m').to_s.gsub(/[ \n]/, '').scan(/.{1,76}/).join("\n ") + "\nEND:VCARD") if avatar && avatar.readable?
card.to_s
end
def contacts_to_vcard(contacts)
return "" unless User.current.allowed_to?(:export_contacts, @project, :global => true)
contacts.map{|c| contact_to_vcard(c) }.join("\r\n")
end
def contacts_to_xls(contacts)
return "" unless User.current.allowed_to?(:export_contacts, @project, :global => true)
require 'spreadsheet'
Spreadsheet.client_encoding = 'UTF-8'
book = Spreadsheet::Workbook.new
sheet = book.create_worksheet
headers = [ "#",
l(:field_is_company),
l(:field_contact_first_name),
l(:field_contact_middle_name),
l(:field_contact_last_name),
l(:field_contact_job_title),
l(:field_contact_company),
l(:field_contact_phone),
l(:field_contact_email),
l(:label_crm_address),
l(:label_crm_city),
l(:label_crm_postcode),
l(:label_crm_region),
l(:label_crm_country),
l(:field_contact_skype),
l(:field_contact_website),
l(:field_birthday),
l(:field_contact_tag_names),
l(:label_crm_assigned_to),
l(:field_contact_background),
l(:field_created_on),
l(:field_updated_on)
]
custom_fields = ContactCustomField.order('LOWER(name)')
custom_fields.each { |f| headers << f.name }
idx = 0
row = sheet.row(idx)
row.replace headers
contacts.each do |contact|
idx += 1
row = sheet.row(idx)
fields = [contact.id,
contact.is_company ? 1 : 0,
contact.first_name,
contact.middle_name,
contact.last_name,
contact.job_title,
contact.company,
contact.phone,
contact.email,
contact.address.to_s.gsub("\r\n"," ").gsub("\n", ' '),
contact.city,
contact.postcode,
contact.region,
contact.country,
contact.skype_name,
contact.website,
format_date(contact.birthday),
contact.tag_list.to_s,
contact.assigned_to ? contact.assigned_to.name : "",
contact.background.to_s.gsub("\r\n"," ").gsub("\n", ' '),
format_date(contact.created_on),
format_date(contact.updated_on)
]
contact.custom_field_values.sort_by{|v| v.custom_field.name.downcase}.each {|custom_value| fields << RedmineContacts::CSVUtils.csv_custom_value(custom_value) }
row.replace fields
end
xls_stream = StringIO.new('')
book.write(xls_stream)
return xls_stream.string
end
def mail_macro(contact, message)
message = message.gsub(/%%NAME%%/, contact.first_name)
message = message.gsub(/%%FULL_NAME%%/, contact.name)
message = message.gsub(/%%COMPANY%%/, contact.company) if contact.company
message = message.gsub(/%%LAST_NAME%%/, contact.last_name) if contact.last_name
message = message.gsub(/%%MIDDLE_NAME%%/, contact.middle_name) if contact.middle_name
message = message.gsub(/%%DATE%%/, format_date(Date.today.to_s))
contact.custom_field_values.each do |value|
message = message.gsub(/%%#{value.custom_field.name}%%/, value.value.to_s)
end
message
end
def set_flash_from_bulk_contact_save(contacts, unsaved_contact_ids)
if unsaved_contact_ids.empty?
flash[:notice] = l(:notice_successful_update) unless contacts.empty?
else
flash[:error] = l(:notice_failed_to_save_contacts,
:count => unsaved_contact_ids.size,
:total => contacts.size,
:ids => '#' + unsaved_contact_ids.join(', #'))
end
end
def render_contact_tabs(tabs)
if tabs.any?
render :partial => 'common/contact_tabs', :locals => {:tabs => tabs}
else
content_tag 'p', l(:label_no_data), :class => "nodata"
end
end
def importer_link
project_contact_imports_path
end
def importer_show_link(importer, project)
project_contact_import_path(:id => importer, :project_id => project)
end
def importer_settings_link(importer, project)
settings_project_contact_import_path(:id => importer, :project => project)
end
def importer_run_link(importer, project)
run_project_contact_import_path(:id => importer, :project_id => project, :format => 'js')
end
def importer_link_to_object(contact)
link_to "#{contact.first_name} #{contact.last_name}", contact_path(contact)
end
def _project_contacts_path(project, *args)
if project
project_contacts_path(project, *args)
else
contacts_path(*args)
end
end
def deals_link_to_remove_fields(name, f, options={})
f.hidden_field(:_destroy) + link_to_function(name, "remove_order_fields(this); tooglePriceField()", options)
end
end
@@ -0,0 +1,24 @@
# encoding: utf-8
#
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module ContactsMoneyHelper
# Will be depricated
end
@@ -0,0 +1,94 @@
# encoding: utf-8
#
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module CrmQueriesHelper
def retrieve_crm_query(object_type)
query_class = Object.const_get("#{object_type.camelcase}Query")
if !params[:query_id].blank?
cond = "project_id IS NULL"
cond << " OR project_id = #{@project.id}" if @project
@query = query_class.where(cond).find(params[:query_id])
raise ::Unauthorized unless @query.visible?
@query.project = @project
session["#{object_type}_query".to_sym] = {:id => @query.id, :project_id => @query.project_id}
sort_clear
elsif api_request? || params[:set_filter] || session["#{object_type}_query".to_sym].nil? || session["#{object_type}_query".to_sym][:project_id] != (@project ? @project.id : nil)
# Give it a name, required to be valid
@query = query_class.new(:name => "_")
@query.project = @project
@query.build_from_params(params)
session["#{object_type}_query".to_sym] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
else
# retrieve from session
@query = query_class.find(session["#{object_type}_query".to_sym][:id]) if session["#{object_type}_query".to_sym][:id]
@query ||= query_class.new(:name => "_", :filters => session["#{object_type}_query".to_sym][:filters], :group_by => session["#{object_type}_query".to_sym][:group_by], :column_names => session["#{object_type}_query".to_sym][:column_names])
@query.project = @project
end
end
def retrieve_crm_calendar(options = {})
if params[:year] and params[:year].to_i > 1900
@year = params[:year].to_i
if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
@month = params[:month].to_i
end
end
@year ||= Date.today.year
@month ||= Date.today.month
@calendar = RedmineContacts::Helpers::CrmCalendar.new(Date.civil(@year, @month, 1), options)
end
def sidebar_crm_queries(query_class)
unless @sidebar_queries
@sidebar_queries = query_class.visible.
where(@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id]).
order("#{query_class.table_name}.name ASC")
end
@sidebar_queries
end
def crm_query_links(title, queries, object_type)
# links to #index on contacts/show
return '' unless queries.any?
url_params = controller_name == "#{object_type}s" ? {:controller => "#{object_type}s", :action => 'index', :project_id => @project} : params
content_tag('h3', title) + "\n" +
content_tag('ul',
queries.collect {|query|
css = 'query'
css << ' selected' if query == @query
content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
}.join("\n").html_safe,
:class => 'queries'
) + "\n"
end
def render_sidebar_crm_queries(object_type)
query_class = Object.const_get("#{object_type.camelcase}Query")
out = ''.html_safe
out << crm_query_links(l(:label_my_queries), sidebar_crm_queries(query_class).select(&:is_private?), object_type)
out << crm_query_links(l(:label_query_plural), sidebar_crm_queries(query_class).reject(&:is_private?), object_type)
out
end
end
@@ -0,0 +1,185 @@
# encoding: utf-8
#
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module DealsHelper
include ContactsHelper
def collection_for_status_select
deal_statuses.collect{|s| [s.name, s.id.to_s]}
end
def deal_status_options_for_select(select="")
options_for_select(collection_for_status_select, select)
end
def deal_statuses
(!@project.blank? ? @project.deal_statuses : DealStatus.order("#{DealStatus.table_name}.status_type, #{DealStatus.table_name}.position")) || []
end
def deal_status_url(status_id, options={})
{:controller => 'deals',
:action => 'index',
:set_filter => 1,
:project_id => @project,
:fields => [:status_id],
:values => {:status_id => [status_id]},
:operators => {:status_id => '='}}.merge(options)
end
def pipeline_status_tag(deal_status, count, index)
total = @processor.scope.count
width ||= 20 if deal_status.is_won?
width ||= 40 if deal_status.is_lost?
width ||= (100 - 20) * (count.to_f / total.to_f) + 20
width_style = index == 0 ? "" : "width: #{width}%"
status_tag = content_tag(:span, deal_status.name)
content_tag(:span, status_tag, :class => "tag-label-color", :style => "background-color:#{deal_status.color_name};color:white; #{width_style}")
end
def remove_contractor_link(contact)
link_to(image_tag('delete.png'),
{:controller => "deal_contacts", :action => 'delete', :project_id => @project, :deal_id => @deal, :contact_id => contact},
:remote => true,
:method => :delete,
:data => {:confirm => l(:text_are_you_sure)},
:class => "delete", :title => l(:button_delete)) if User.current.allowed_to?(:edit_deals, @project)
end
def link_to_deal(deal)
link_to deal.name, deal_path(deal)
end
def deal_list_styles_for_select
[[l(:label_crm_list_excerpt), "list_excerpt"],
[l(:label_crm_list_list), "list"],
[l(:label_crm_list_board), "list_board"],
[l(:label_calendar), "crm_calendars/crm_calendar"],
[l(:label_crm_pipeline), "list_pipeline"]]
end
def deals_list_style
list_styles = deal_list_styles_for_select.map(&:last)
if params[:deals_list_style].blank?
list_style = list_styles.include?(session[:deals_list_style]) ? session[:deals_list_style] : RedmineContacts.default_list_style.gsub("list_cards", "list_board")
else
list_style = list_styles.include?(params[:deals_list_style]) ? params[:deals_list_style] : RedmineContacts.default_list_style.gsub("list_cards", "list_board")
end
session[:deals_list_style] = list_style
end
def retrieve_deals_query
if params[:status_id] || !params[:period].blank? || !params[:category_id].blank? || !params[:assigned_to_id].blank?
session[:deals_query] = {:project_id => (@project ? @project.id : nil),
:status_id => params[:status_id],
:category_id => params[:category_id],
:period => params[:period],
:assigned_to_id => params[:assigned_to_id]}
else
if api_request? || params[:set_filter] || session[:deals_query].nil? || session[:deals_query][:project_id] != (@project ? @project.id : nil)
session[:deals_query] = {}
else
params.merge!(session[:deals_query])
end
end
end
def pipeline_prices(scope)
prices_collection_by_currency(scope.group_by(&:currency).map{|k,v| [k, v.inject(0) { |sum, x| sum + x.price.to_f } ] }).join(' / ').html_safe
end
def deals_to_csv(deals)
return "" unless User.current.allowed_to?(:export_contacts, @project, :global => true)
decimal_separator = l(:general_csv_decimal_separator)
encoding = 'utf-8'
export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
# csv header fields
headers = [ "#",
l(:field_name, :locale => :en),
l(:field_background, :locale => :en),
l(:field_currency, :locale => :en),
l(:field_price, :locale => :en),
l(:label_crm_probability, :locale => :en),
l(:label_crm_expected_revenue, :locale => :en),
l(:field_due_date, :locale => :en),
l(:field_author, :locale => :en),
l(:field_assigned_to, :locale => :en),
l(:field_status, :locale => :en),
l(:field_contact, :locale => :en),
l(:field_category, :locale => :en),
l(:field_created_on, :locale => :en),
l(:field_updated_on, :locale => :en)
]
custom_fields = DealCustomField.order(:name)
custom_fields.each {|f| headers << f.name}
csv << headers.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) }
# csv lines
deals.each do |deal|
fields = [deal.id,
deal.name,
deal.background,
deal.currency,
deal.price,
deal.probability,
deal.expected_revenue,
format_date(deal.due_date),
deal.author,
deal.assigned_to,
deal.status,
deal.contact,
deal.category,
format_date(deal.created_on),
format_date(deal.updated_on)
]
deal.custom_field_values.sort_by{|v| v.custom_field.name}.each {|custom_value| fields << RedmineContacts::CSVUtils.csv_custom_value(custom_value) }
csv << fields.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) }
end
end
export
end
def importer_link
project_deal_imports_path
end
def importer_show_link(importer, project)
project_deal_import_path(:id => importer, :project_id => project)
end
def importer_settings_link(importer, project)
settings_project_deal_import_path(:id => importer, :project => project)
end
def importer_run_link(importer, project)
run_project_deal_import_path(:id => importer, :project_id => project, :format => 'js')
end
def importer_link_to_object(deal)
link_to deal.name, deal_path(deal)
end
def _project_deals_path(project, *args)
if project
project_deals_path(project, *args)
else
deals_path(*args)
end
end
end
@@ -0,0 +1,132 @@
# encoding: utf-8
#
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module NotesHelper
include ContactsHelper
def collection_for_note_types_select
note_types = [[l(:label_crm_note), '']] + [:label_crm_note_type_email, :label_crm_note_type_call, :label_crm_note_type_meeting].each_with_index.collect{|type, i| [l(type), i]}
context = {:note_types => note_types}
call_hook(:helper_notes_note_type_label, context)
context[:note_types]
end
def authoring_note(created, author, options={})
return "<span class=\"author\">#{l(options[:label] || :label_crm_added_by)} #{link_to_user(author).to_s}</span>".html_safe if created.blank?
if RedmineContacts.settings[:note_authoring_time]
('<span class="author">' + l(options[:label] || :label_crm_added_by) + ' ' +
link_to_user(author).to_s + ', ' +
format_time(created).to_s + '</span>').html_safe
else
authoring(created, author, options={})
end
end
def add_note_url(note_source, project=nil)
{:controller => 'notes', :action => 'create', :source_id => note_source, :source_type => note_source.class.name, :project_id => project}
end
def contacts_thumbnails(obj, options={})
return false if !obj || !obj.respond_to?(:attachments)
options[:size] = options[:size].to_s || "100"
size = options[:size]
options[:size] = options[:size] + "x" + options[:size]
# options[:max_width] = size
# options[:max_heght] = size
max_file_size = options[:max_file_size] || 300.kilobytes
options[:class] = "thumbnail"
s = ""
# TODO: Regexp does not work
images = obj.attachments.select{|att| att.thumbnailable?}
images = images.select{|att| att.filename.match(options[:regexp])} if options[:regexp]
images.each do |att_file|
attachment_url = url_for :controller => 'attachments', :action => 'download', :id => att_file, :filename => att_file.filename
contacts_thumbnail_url = url_for(:controller => 'attachments',
:action => 'contacts_thumbnail',
:id => att_file,
:size => size)
image_url = Redmine::Thumbnail.convert_available? ? contacts_thumbnail_url : attachment_url
s << link_to(image_tag(image_url, options), attachment_url, {:title => att_file.filename}) if (att_file.filesize < max_file_size || Redmine::Thumbnail.convert_available?)
end
s.html_safe
end
def auto_contacts_thumbnails(obj)
s = ""
max_file_size = Setting.plugin_redmine_contacts[:max_contacts_thumbnail_file_size].to_i.kilobytes if !Setting.plugin_redmine_contacts[:max_contacts_thumbnail_file_size].blank?
s << contacts_thumbnails(obj, {:size => 100, :max_file_size => max_file_size}) if Setting.plugin_redmine_contacts[:auto_contacts_thumbnails]
s = content_tag(:p, s.html_safe, :class => "thumbnail") if !s.blank?
s.html_safe
end
def note_content(note)
s = ''
if note.content.length > Note.cut_length
if ActiveRecord::VERSION::MAJOR >= 4
s << truncate(note.content, :length => Note.cut_length) { link_to "#{l(:label_crm_note_read_more)}", note_path(:id => note, :project_id => @project) }
else
s << textilizable(truncate(note.content, :length => Note.cut_length,
:omission => "... \"#{l(:label_crm_note_read_more)}\":#{url_for(:controller => 'notes',
:action => 'show',
:project_id => @project,
:id => note)}"))
end
else
s << textilizable(note, :content)
end
s.html_safe
end
def notes_to_csv(notes)
decimal_separator = l(:general_csv_decimal_separator)
encoding = l(:general_csv_encoding)
export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
# csv header fields
headers = [ "#",
l(:field_type, :locale => :en),
l(:label_date, :locale => :en),
l(:field_author, :locale => :en),
l(:field_content, :locale => :en)
]
# Export project custom fields if project is given
# otherwise export custom fields marked as "For all projects"
custom_fields = NoteCustomField.order(:name)
custom_fields.each {|f| headers << f.name}
# Description in the last column
csv << headers.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) }
# csv lines
notes.each do |note|
fields = [note.id,
note.type_id,
format_time(note.created_on),
note.author.name,
note.content
]
custom_fields.each {|f| fields << RedmineContacts::CSVUtils.csv_custom_value(note.custom_value_for(f)) }
csv << fields.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) }
end
end
export
end
end
@@ -0,0 +1,81 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class Address < ActiveRecord::Base
include Redmine::SafeAttributes
attr_reader :country
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'street1', 'street2', 'region', 'city', 'country_code', 'postcode',
'full_address', 'address_type', 'addressable'
belongs_to :addressable, :polymorphic => true
scope :business, lambda { where(:address_type => 'business') }
scope :billing, lambda { where(:address_type => 'billing') }
scope :shipping, lambda { where(:address_type => 'shipping') }
before_save :populate_full_address
def country
@country ||= l(:label_crm_countries)[country_code.to_sym].to_s unless country_code.blank?
end
def blank?
%w(street1 street2 city region postcode country_code).all? { |attr| self.send(attr).blank? }
end
#----------------------------------------------------------------------------
# Ensure blank address records don't get created. If we have a new record and
# address is empty then return true otherwise return false so that _destroy
# is processed (if applicable) and the record is removed.
# Intended to be called as follows:
# accepts_nested_attributes_for :business_address, :allow_destroy => true, :reject_if => proc {|attributes| Address.reject_address(attributes)}
def self.reject_address(attributes)
exists = attributes['id'].present?
empty = %w(street1 street2 city region postcode country_code full_address).map { |name| attributes[name].blank? }.all?
attributes[:_destroy] = 1 if exists && empty
!exists && empty
end
def to_s
%w(street1 street2 city postcode region country).map { |attr| send(attr) }.select { |a| !a.blank? }.join(', ')
end
def post_address
address_template = ContactsSetting.post_address_format
address_template = address_template.gsub('%street1%', street1.to_s)
address_template = address_template.gsub('%street2%', street2.to_s)
address_template = address_template.gsub('%city%', city.to_s)
address_template = address_template.gsub('%town%', city.to_s)
address_template = address_template.gsub('%postcode%', postcode.to_s)
address_template = address_template.gsub('%zip%', postcode.to_s)
address_template = address_template.gsub('%region%', region.to_s)
address_template = address_template.gsub('%state%', region.to_s)
address_template = address_template.gsub('%country%', country.to_s)
address_template.gsub(/\r\n?/, "\n").gsub(/^$\n/, '').gsub(/^[, ]+|[, ]+$|[,]{2,}/,'').gsub(/\s{2,}/, ' ').strip
end
private
def populate_full_address
self.full_address = self.to_s
end
end
+519
View File
@@ -0,0 +1,519 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class Contact < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
CONTACT_FORMATS = {
:firstname_lastname => {
:string => '#{first_name} #{last_name}',
:order => %w(first_name middle_name last_name id),
:setting_order => 1
},
:lastname_firstname_middlename => {
:string => '#{last_name} #{first_name} #{middle_name}',
:order => %w(last_name first_name middle_name id),
:setting_order => 1
},
:firstname_middlename_lastname => {
:string => '#{first_name} #{middle_name} #{last_name}',
:order => %w(first_name middle_name last_name id),
:setting_order => 1
},
:firstname_lastinitial => {
:string => '#{first_name} #{middle_name.to_s.chars.first + \'.\' unless middle_name.blank?} #{last_name.to_s.chars.first + \'.\' unless last_name.blank?}',
:order => %w(first_name middle_name last_name id),
:setting_order => 2
},
:firstinitial_lastname => {
:string => '#{first_name.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{middle_name.to_s.chars.first + \'.\' unless middle_name.blank?} #{last_name}',
:order => %w(first_name middle_name last_name id),
:setting_order => 2
},
:lastname_firstinitial => {
:string => '#{last_name} #{first_name.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{middle_name.to_s.chars.first + \'.\' unless middle_name.blank?}',
:order => %w(last_name first_name middle_name id),
:setting_order => 2
},
:firstname => {
:string => '#{first_name}',
:order => %w(first_name middle_name id),
:setting_order => 3
},
:lastname_firstname => {
:string => '#{last_name} #{first_name}',
:order => %w(last_name first_name middle_name id),
:setting_order => 4
},
:lastname_coma_firstname => {
:string => '#{last_name.to_s + \',\' unless last_name.blank?} #{first_name}',
:order => %w(last_name first_name middle_name id),
:setting_order => 5
},
:lastname => {
:string => '#{last_name}',
:order => %w(last_name id),
:setting_order => 6
}
}
VISIBILITY_PROJECT = 0
VISIBILITY_PUBLIC = 1
VISIBILITY_PRIVATE = 2
delegate :street1, :street2, :city, :country, :country_code, :postcode, :region, :post_address, :to => :address, :allow_nil => true
has_many :notes, :as => :source, :class_name => 'ContactNote', :dependent => :delete_all
has_many :addresses, :dependent => :destroy, :as => :addressable, :class_name => 'Address'
belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
if ActiveRecord::VERSION::MAJOR >= 4
has_one :avatar, lambda { where("#{Attachment.table_name}.description = 'avatar'") }, :class_name => 'Attachment', :as => :container, :dependent => :destroy
has_one :address, lambda { where(:address_type => 'business') }, :dependent => :destroy, :as => :addressable, :class_name => 'Address'
has_many :deals, lambda { order("#{Deal.table_name}.status_id") }
has_and_belongs_to_many :related_deals, lambda { order("#{Deal.table_name}.status_id") }, :uniq => true, :class_name => 'Deal'
has_and_belongs_to_many :projects, :uniq => true
has_and_belongs_to_many :issues, lambda { order("#{Issue.table_name}.due_date") }, :uniq => true
else
has_one :avatar, :conditions => "#{Attachment.table_name}.description = 'avatar'", :class_name => 'Attachment', :as => :container, :dependent => :destroy
has_one :address, :conditions => { :address_type => 'business' }, :dependent => :destroy, :as => :addressable, :class_name => 'Address'
has_many :deals, :order => "#{Deal.table_name}.status_id"
has_and_belongs_to_many :related_deals, :order => "#{Deal.table_name}.status_id", :class_name => 'Deal', :uniq => true
has_and_belongs_to_many :projects, :uniq => true
has_and_belongs_to_many :issues, :order => "#{Issue.table_name}.due_date", :uniq => true
end
attr_accessor :phones
attr_accessor :emails
acts_as_customizable
acts_as_viewable
rcrm_acts_as_taggable
acts_as_watchable
acts_as_attachable :view_permission => :view_contacts,
:delete_permission => :edit_contacts
acts_as_event :datetime => :created_on,
:url => lambda { |o| { :controller => 'contacts', :action => 'show', :id => o } },
:type => 'icon icon-contact',
:title => lambda { |o| o.name },
:description => lambda { |o| [o.info, o.company, o.email, o.address, o.background].join(' ') }
if ActiveRecord::VERSION::MAJOR >= 4
acts_as_activity_provider :type => 'contacts',
:permission => :view_contacts,
:author_key => :author_id,
:scope => joins(:projects)
acts_as_searchable :columns => ["#{table_name}.first_name",
"#{table_name}.middle_name",
"#{table_name}.last_name",
"#{table_name}.company",
"#{table_name}.email",
"#{Address.table_name}.full_address",
"#{table_name}.background",
"#{ContactNote.table_name}.content"],
:project_key => "#{Project.table_name}.id",
:scope => includes([:address, :notes]),
:date_column => "created_on"
else
acts_as_activity_provider :type => 'contacts',
:permission => :view_contacts,
:author_key => :author_id,
:find_options => { :include => :projects }
acts_as_searchable :columns => ["#{table_name}.first_name",
"#{table_name}.middle_name",
"#{table_name}.last_name",
"#{table_name}.company",
"#{table_name}.email",
"#{Address.table_name}.full_address",
"#{table_name}.background",
"#{ContactNote.table_name}.content"],
:project_key => "#{Project.table_name}.id",
:include => [:projects, :address, :notes],
# sort by id so that limited eager loading doesn't break with postgresql
:order_column => "#{table_name}.id"
end
accepts_nested_attributes_for :address, :allow_destroy => true, :update_only => true, :reject_if => proc { |attributes| Address.reject_address(attributes) }
scope :visible, lambda { |*args| eager_load(:projects).where(Contact.visible_condition(args.shift || User.current, *args)) }
scope :deletable, lambda { |*args| eager_load(:projects).where(Contact.deletable_condition(args.shift || User.current, *args)).readonly(false) }
scope :editable, lambda { |*args| eager_load(:projects).where(Contact.editable_condition(args.shift || User.current, *args)).readonly(false) }
scope :by_project, lambda { |prj| joins(:projects).where("#{Project.table_name}.id = ?", prj) unless prj.blank? }
scope :like_by, lambda { |field, search| {:conditions => ["LOWER(#{Contact.table_name}.#{field}) LIKE ?", search.downcase + "%"] }}
scope :companies, lambda { where(:is_company => true) }
scope :people, lambda { where(:is_company => false) }
scope :order_by_name, lambda { order(Contact.fields_for_order_statement) }
scope :order_by_creation, lambda { order("#{Contact.table_name}.created_on DESC") }
scope :by_full_name, lambda { |search| where("LOWER(CONCAT(#{Contact.table_name}.first_name,' ',#{Contact.table_name}.last_name)) = ? ", search.downcase) }
scope :by_name, lambda { |search| where("(LOWER(#{Contact.table_name}.first_name) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.last_name) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.middle_name) LIKE LOWER(:p))",
{ :p => '%' + search.downcase + '%' }) }
scope :live_search, lambda {|search| where("(LOWER(#{Contact.table_name}.first_name) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.last_name) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.middle_name) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.company) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.email) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.phone) LIKE LOWER(:p) OR
LOWER(#{Contact.table_name}.job_title) LIKE LOWER(:p))",
{ :p => '%' + search.downcase + '%' }) }
validates_presence_of :first_name, :project
validate :emails_format
# validates_uniqueness_of :first_name, :scope => [:last_name, :company, :email]
before_validation :strip_email
after_create :send_notification
before_save :update_company_contacts
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'is_company',
'first_name',
'last_name',
'middle_name',
'company',
'website',
'skype_name',
'birthday',
'job_title',
'background',
'author_id',
'assigned_to_id',
'phone',
'email',
'tag_list',
'project_ids',
'visibility',
'custom_field_values',
'custom_fields',
'watcher_user_ids',
'address_attributes'
def self.visible_condition(user, options = {})
user.reload
user_ids = [user.id] + user.groups.map(&:id)
projects_allowed_to_view_contacts = Project.where(Project.allowed_to_condition(user, :view_contacts)).pluck(:id)
allowed_to_view_condition = projects_allowed_to_view_contacts.empty? ? "(1=0)" : "#{Project.table_name}.id IN (#{projects_allowed_to_view_contacts.join(',')})"
projects_allowed_to_view_private = Project.where(Project.allowed_to_condition(user, :view_private_contacts)).pluck(:id)
allowed_to_view_private_condition = projects_allowed_to_view_private.empty? ? "(1=0)" : "#{Project.table_name}.id IN (#{projects_allowed_to_view_private.join(',')})"
cond = "(#{Project.table_name}.id <> -1 ) AND ("
if user.admin?
cond << "(#{table_name}.visibility = 1) OR (#{allowed_to_view_condition}) "
else
cond << " (#{table_name}.visibility = 1) OR" if user.allowed_to_globally?(:view_contacts, {})
cond << " (#{allowed_to_view_condition} AND #{table_name}.visibility <> 2) "
if user.logged?
cond << " OR (#{allowed_to_view_private_condition} " +
" OR (#{allowed_to_view_condition} " +
" AND (#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}) )))"
end
end
cond << ')'
end
def self.editable_condition(user, options = {})
visible_condition(user, options) + " AND (#{Project.allowed_to_condition(user, :edit_contacts)})"
end
def self.deletable_condition(user, options = {})
visible_condition(user, options) + " AND (#{Project.allowed_to_condition(user, :delete_contacts)})"
end
def all_deals
@all_deals ||= (deals + related_deals).uniq.sort! { |x, y| x.status_id <=> y.status_id }
end
def all_visible_deals(usr = User.current)
@all_deals ||= (deals.visible(usr) + related_deals.visible(usr)).uniq.sort! { |x, y| x.status_id <=> y.status_id }
if is_company?
company_contacts.each { |contact| @all_deals += contact.deals }
end
@all_deals.uniq.sort! { |x, y| x.status_id <=> y.status_id }
end
def self.available_tags(options = {})
limit = options[:limit]
scope = RedmineCrm::Tag.where({})
scope = scope.where("#{Project.table_name}.id = ?", options[:project]) if options[:project]
scope = scope.where(Contact.visible_condition(options[:user] || User.current))
scope = scope.where("LOWER(#{RedmineCrm::Tag.table_name}.name) LIKE ?", "%#{options[:name_like].downcase}%") if options[:name_like]
joins = []
joins << "JOIN #{RedmineCrm::Tagging.table_name} ON #{RedmineCrm::Tagging.table_name}.tag_id = #{RedmineCrm::Tag.table_name}.id "
joins << "JOIN #{Contact.table_name} ON #{Contact.table_name}.id = #{RedmineCrm::Tagging.table_name}.taggable_id AND #{RedmineCrm::Tagging.table_name}.taggable_type = '#{Contact.name}' "
joins << Contact.projects_joins
scope = scope.select("#{RedmineCrm::Tag.table_name}.*, COUNT(DISTINCT #{RedmineCrm::Tagging.table_name}.taggable_id) AS count")
scope = scope.joins(joins.flatten)
scope = scope.group("#{RedmineCrm::Tag.table_name}.id, #{RedmineCrm::Tag.table_name}.name HAVING COUNT(*) > 0")
scope = scope.limit(limit) if limit
scope = scope.order("#{RedmineCrm::Tag.table_name}.name")
scope
end
def duplicates(limit = 10)
scope = Contact.where({})
cond = "((1=1) "
cond << "AND LOWER(#{Contact.table_name}.first_name) LIKE LOWER('#{first_name.strip}') " unless first_name.blank?
cond << "AND (LOWER(#{Contact.table_name}.middle_name) LIKE LOWER('#{middle_name.strip}') OR middle_name LIKE '') " unless middle_name.blank?
cond << "AND LOWER(#{Contact.table_name}.last_name) LIKE LOWER('#{last_name.strip}') " unless last_name.blank?
cond << " OR LOWER(#{Contact.table_name}.email) LIKE LOWER('#{primary_email.strip}') " unless primary_email.blank?
cond << ")"
cond << " AND #{Contact.table_name}.id <> #{id}" unless new_record?
scope = scope.where(cond)
@duplicates ||= (first_name.blank? && last_name.blank? && middle_name.blank?) ? [] : scope.visible.limit(limit)
end
def company_contacts
@contacts ||= Contact.order_by_name.includes(:avatar).where(["#{Contact.table_name}.is_company = ? AND #{Contact.table_name}.company = ? AND #{Contact.table_name}.id <> ?", false, first_name, id])
end
alias_method :employees, :company_contacts
def redmine_user
if ActiveRecord::VERSION::MAJOR >= 4
@redmine_user ||= User.joins(:email_address).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", emails).first unless email.blank?
else
@redmine_user ||= User.where(:mail => emails).first unless email.blank?
end
end
def contact_company
@contact_company ||= Contact.where(:first_name => company, :is_company => true).
where("#{Contact.table_name}.id <> #{id.to_i}").first unless company.blank?
end
def notes_attachments
@contact_attachments ||= Attachment.where(:container_type => 'Note', :container_id => notes.map(&:id)).order(:created_on)
end
# usr for mailer
def visible?(usr = nil)
usr ||= User.current
if is_public?
usr.allowed_to_globally?(:view_contacts, {})
else
allowed_to?(usr || User.current, :view_contacts)
end
end
def editable?(usr = nil)
allowed_to?(usr || User.current, :edit_contacts)
end
def deletable?(usr = nil)
allowed_to?(usr || User.current, :delete_contacts)
end
def allowed_to?(user, action, options = {})
if is_private?
(projects.map { |p| user.allowed_to?(action, p) }.compact.any? && (author == user || user.is_or_belongs_to?(assigned_to))) ||
(projects.map { |p| user.allowed_to?(:view_private_contacts, p) }.compact.any? && projects.map { |p| user.allowed_to?(action, p) }.compact.any?)
else
projects.map { |p| user.allowed_to?(action, p) }.compact.any?
end
end
def is_public?
visibility == VISIBILITY_PUBLIC
end
def is_private?
visibility == VISIBILITY_PRIVATE
end
def send_mail_allowed?(usr = nil)
usr ||= User.current
@send_mail_allowed ||= 0 < projects.visible(usr).where(Project.allowed_to_condition(usr, :send_contacts_mail)).count
end
def self.projects_joins
joins = []
joins << ["JOIN contacts_projects ON contacts_projects.contact_id = #{table_name}.id"]
joins << ["JOIN #{Project.table_name} ON contacts_projects.project_id = #{Project.table_name}.id"]
end
def project(current_project=nil)
return @project if @project
visible_projects = Project.visible.where(:id => projects.pluck(:id))
if current_project && visible_projects.include?(current_project)
@project = current_project
else
@project = visible_projects.where(Project.allowed_to_condition(User.current, :view_contacts)).first
end
@project ||= projects.first
end
def project=(project)
projects << project
end
def self.find_by_emails(emails)
cond = '(1 = 0)'
emails = emails.map(&:downcase)
emails.each do |mail|
cond << " OR (LOWER(#{Contact.table_name}.email) LIKE LOWER('%#{mail.gsub("'", "").gsub("\"", "")}%'))"
end
contacts = Contact.where(cond)
contacts.select { |c| (c.emails.map(&:downcase) & emails).any? }
end
def self.name_formatter(formatter = nil)
CONTACT_FORMATS[formatter || ContactsSetting.contact_name_format.to_sym]
end
# Returns an array of fields names than can be used to make an order statement for users
# according to how user names are displayed
# Examples:
#
# Contact.fields_for_order_statement => ['contacts.first_name', 'contacts.first_name', 'contacts.id']
# Contact.fields_for_order_statement('customers') => ['customers.last_name', 'customers.id']
def self.fields_for_order_statement(table = nil)
table ||= table_name
name_formatter[:order].map { |field| "#{table}.#{field}" }
end
# Return contacts's full name for display
def name(formatter = nil)
unless is_company?
f = self.class.name_formatter(formatter)
if formatter
eval('"' + f[:string] + '"')
else
@name ||= eval('"' + f[:string] + '"')
end
else
first_name
end
end
def name_with_company
return name if company.blank?
[name, ' ', '(', company, ')'].join
end
def info
job_title
end
def phones
@phones || phone ? phone.split(/, */) : []
end
def emails
@emails || email ? email.split(/, */).map { |m| m.strip } : []
end
def primary_email
emails.first
end
def age
return nil if birthday.blank?
now = Time.now
# how many years?
# has their birthday occured this year yet?
# subtract 1 if so, 0 if not
now.year - birthday.year - (birthday.to_time.change(:year => now.year) > now ? 1 : 0)
end
def website_address
website.match("^https?://") ? website : website.gsub(/^/, "http://") unless website.blank?
end
def to_s
name
end
def notified_users
notified = []
# Author and assignee are always notified unless they have been
# locked or don't want to be notified
notified << author if author
if assigned_to
notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
end
notified += project.notified_users
if !is_company && !contact_company.blank?
notified += contact_company.notified_users
end
notified = notified.select { |u| u.active? }
notified.uniq!
# Remove users that can not view the issue
notified.reject! { |user| !visible?(user) }
notified
end
# Returns the mail adresses of users that should be notified
def recipients
notified_users.collect(&:mail)
end
def all_watcher_recepients
notified = watcher_recipients
if !is_company && !contact_company.blank?
notified += contact_company.watcher_recipients
end
notified
end
private
def assign_phone
if @phones
self.phone = @phones.uniq.map { |s| s.strip.delete(',').squeeze(' ') }.join(', ')
end
end
def send_notification
Mailer.crm_contact_add(self).deliver if Setting.notified_events.include?('crm_contact_added')
end
def strip_email
return unless email
self.email = email.tr(' ', '')
end
def emails_format
return unless email
validate_result = email.split(',').all? { |email| email.match(/\A[^@]+@[^@]+\z/) }
errors.add(:email, I18n.t(:text_crm_string_incorrect_format)) unless validate_result
end
def update_company_contacts
return unless is_company
return unless first_name_changed?
Contact.where(["#{Contact.table_name}.is_company = ? AND #{Contact.table_name}.company = ?", false, first_name_was]).
update_all(:company => first_name)
end
end
@@ -0,0 +1,26 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactCustomField < CustomField
unloadable
def type_name
:label_contact_plural
end
end
@@ -0,0 +1,51 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactImport
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
include CSVImportable
attr_accessor :file, :project, :tag_list, :quotes_type
def klass
Contact
end
def build_from_fcsv_row(row)
ret = Hash[row.to_hash.collect { |k, v| [k.underscore.tr(' ', '_'), force_utf8(v)] if k }].delete_if { |k, _v| !klass.column_names.include?(k) }
ret[:birthday] = row['birthday'].to_date if row['birthday']
ActiveRecord::VERSION::MAJOR >= 4 ? ret[:tag_list] = [row['tags'], tag_list] : ret[:tag_list] = [row['tags'], tag_list].join(',')
ret[:assigned_to_id] = User.find_by_login(row['responsible']).try(:id) unless row['responsible'].blank?
unless row['address'].blank? && row['city'].blank? && row['street1'].blank? && row['street2'].blank? && row['region'].blank? && row['postcode'].blank? && row['country_code'].blank?
ret[:address_attributes] = {}
ret[:address_attributes][:street1] = row['address'] unless row['address'].blank?
ret[:address_attributes][:street2] = row['street2'] unless row['street2'].blank?
ret[:address_attributes][:city] = row['city'] unless row['city'].blank?
ret[:address_attributes][:postcode] = row['postcode'] unless row['postcode'].blank?
ret[:address_attributes][:postcode] = row['zip'] unless row['zip'].blank?
ret[:address_attributes][:region] = row['region'] unless row['region'].blank?
ret[:address_attributes][:country_code] = row['country code'] unless row['country code'].blank?
ret[:address_attributes][:country] = row['country'] unless row['country'].blank?
ret[:address_attributes][:region] = row['state'] unless row['state'].blank? && !row["region"].blank?
end
ret
end
end
@@ -0,0 +1,130 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactKernelImport < Import
def klass
Contact
end
def saved_objects
object_ids = saved_items.pluck(:obj_id)
Contact.where(:id => object_ids).order(:id)
end
def project=(project)
settings['project'] = project.id
end
def project
settings['project']
end
private
def build_object(row, _item = nil)
contact = Contact.new
contact.project = Project.find(settings['project'])
contact.author = user
attributes = {}
if is_company = row_value(row, 'is_company')
attributes['is_company'] = '1' if yes?(is_company)
end
if first_name = row_value(row, 'first_name')
attributes['first_name'] = first_name
end
if middle_name = row_value(row, 'middle_name')
attributes['middle_name'] = middle_name
end
if last_name = row_value(row, 'last_name')
attributes['last_name'] = last_name
end
if job_title = row_value(row, 'job_title')
attributes['job_title'] = job_title
end
if company = row_value(row, 'company')
attributes['company'] = company
end
if phone = row_value(row, 'phone')
attributes['phone'] = phone
end
if email = row_value(row, 'email')
attributes['email'] = email
end
address_attributes = {}
if address_street = row_value(row, 'address_street')
address_attributes['street1'] = address_street
end
if address_country_code = row_value(row, 'address_country_code')
address_attributes['country_code'] = address_country_code
end
if address_zip = row_value(row, 'address_zip')
address_attributes['postcode'] = address_zip
end
if address_state = row_value(row, 'address_state')
address_attributes['region'] = address_state
end
if address_city = row_value(row, 'address_city')
address_attributes['city'] = address_city
end
attributes['address_attributes'] = address_attributes
if skype_name = row_value(row, 'skype_name')
attributes['skype_name'] = skype_name
end
if website = row_value(row, 'website')
attributes['website'] = website
end
if birthday = row_value(row, 'birthday')
attributes['birthday'] = birthday
end
if tag_list = row_value(row, 'tag_list')
attributes['tag_list'] = tag_list
end
if background = row_value(row, 'background')
attributes['background'] = background
end
attributes['custom_field_values'] = contact.custom_field_values.inject({}) do |h, v|
value = case v.custom_field.field_format
when 'date'
row_date(row, "cf_#{v.custom_field.id}")
when 'list'
row_value(row, "cf_#{v.custom_field.id}").try(:split, ',')
else
row_value(row, "cf_#{v.custom_field.id}")
end
if value
h[v.custom_field.id.to_s] =
if value.is_a?(Array)
value.map { |val| v.custom_field.value_from_keyword(val.strip, contact) }.compact.flatten
else
v.custom_field.value_from_keyword(value, contact)
end
end
h
end
contact.send :safe_attributes=, attributes, user
contact
end
end
@@ -0,0 +1,50 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactNote < Note
unloadable
include Redmine::SafeAttributes
belongs_to :contact, :foreign_key => :source_id
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'subject', 'type_id', 'content', 'source', 'author_id'
if ActiveRecord::VERSION::MAJOR >= 4
if ActiveRecord::Base.connection.table_exists?('notes')
acts_as_activity_provider :type => 'contacts',
:permission => :view_contacts,
:author_key => :author_id,
:scope => eager_load(:contact => :projects).where(:source_type => 'Contact')
end
else
acts_as_activity_provider :type => 'contacts',
:permission => :view_contacts,
:author_key => :author_id,
:find_options => { :include => [:contact => :projects], :conditions => { :source_type => 'Contact' } }
end
scope :visible,
lambda { |*args| joins([:contact => :projects]).
where(Contact.visible_condition(args.shift || User.current, *args) +
" AND (#{ContactNote.table_name}.source_type = 'Contact')") }
acts_as_attachable :view_permission => :view_contacts,
:delete_permission => :edit_contacts
end
@@ -0,0 +1,240 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactQuery < Query
include CrmQuery
class QueryMultipleValuesColumn < QueryColumn
def value_object(object)
value = super
value.respond_to?(:to_a) ? value.to_a : value
end
end
self.queried_class = Contact
self.view_permission = :view_contacts if Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch?
self.available_columns = [
QueryColumn.new(:id, :sortable => "#{Contact.table_name}.id", :default_order => 'desc', :caption => '#'),
QueryColumn.new(:name, :sortable => lambda {Contact.fields_for_order_statement}, :caption => :field_contact_full_name),
QueryColumn.new(:first_name, :sortable => "#{Contact.table_name}.first_name"),
QueryColumn.new(:last_name, :sortable => "#{Contact.table_name}.last_name"),
QueryColumn.new(:middle_name, :sortable => "#{Contact.table_name}.middle_name", :caption => :field_contact_middle_name),
QueryColumn.new(:job_title, :sortable => "#{Contact.table_name}.job_title", :caption => :field_contact_job_title, :groupable => true),
QueryColumn.new(:company, :sortable => "#{Contact.table_name}.company", :groupable => "#{Contact.table_name}.company", :caption => :field_contact_company),
QueryColumn.new(:phones, :sortable => "#{Contact.table_name}.phone", :caption => :field_contact_phone),
QueryColumn.new(:emails, :sortable => "#{Contact.table_name}.email", :caption => :field_contact_email),
QueryColumn.new(:address, :sortable => "#{Address.table_name}.full_address", :caption => :label_crm_address),
QueryColumn.new(:street1, :sortable => "#{Address.table_name}.street1", :caption => :label_crm_street1),
QueryColumn.new(:street2, :sortable => "#{Address.table_name}.street2", :caption => :label_crm_street2),
QueryColumn.new(:city, :sortable => "#{Address.table_name}.city", :groupable => "#{Address.table_name}.city", :caption => :label_crm_city),
QueryColumn.new(:region, :sortable => "#{Address.table_name}.region", :caption => :label_crm_region),
QueryColumn.new(:postcode, :sortable => "#{Address.table_name}.postcode", :caption => :label_crm_postcode),
QueryColumn.new(:country, :sortable => "#{Address.table_name}.country_code", :groupable => "#{Address.table_name}.country_code", :caption => :label_crm_country),
QueryMultipleValuesColumn.new(:tags, :caption => :label_crm_tags_plural),
QueryColumn.new(:created_on, :sortable => "#{Contact.table_name}.created_on"),
QueryColumn.new(:updated_on, :sortable => "#{Contact.table_name}.updated_on"),
QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")})
]
def initialize(attributes=nil, *args)
super attributes
self.filters ||= {}
end
def initialize_available_filters
add_available_filter "ids", :type => :integer, :label => :label_contact if Redmine::VERSION.to_s >= '3.3'
add_available_filter "first_name", :type => :string, :order => 0
add_available_filter "last_name", :type => :string, :order => 1
add_available_filter "middle_name", :type => :string, :order => 2
add_available_filter "job_title", :type => :string, :order => 3
add_available_filter "company", :type => :string, :order => 4
add_available_filter "phone", :type => :text, :order => 5
add_available_filter "email", :type => :text, :order => 6
add_available_filter "full_address", :type => :text, :order => 7, :name => l(:label_crm_address)
add_available_filter "street1", :type => :text, :order => 8, :name => l(:label_crm_street1)
add_available_filter "street2", :type => :text, :order => 8, :name => l(:label_crm_street2)
add_available_filter "city", :type => :text, :order => 8, :name => l(:label_crm_city)
add_available_filter "region", :type => :text, :order => 9, :name => l(:label_crm_region)
add_available_filter "postcode", :type => :text, :order => 10, :name => l(:label_crm_postcode)
add_available_filter "country", :type => :list_optional, :values => l(:label_crm_countries).map{|k, v| [v, k]}, :order => 11, :name => l(:label_crm_country)
add_available_filter "is_company", :type => :list, :values => [[l(:general_text_yes), ActiveRecord::Base.connection.quoted_true.gsub(/'/, '')], [l(:general_text_no), ActiveRecord::Base.connection.quoted_false.gsub(/'/, '')]], :order => 12
add_available_filter "last_note", :type => :date_past, :order => 13
add_available_filter "has_deals", :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 14, :name => l(:label_crm_has_deals)
add_available_filter "updated_on", :type => :date_past, :order => 20
add_available_filter "created_on", :type => :date, :order => 21
add_available_filter "tags", :type => :list, :values => Contact.available_tags(project.blank? ? {} : {:project => project.id}).collect{ |t| [t.name, t.name] }, :order => 12
initialize_author_filter
initialize_assignee_filter
add_available_filter("has_open_issues",
:type => :list_optional, :values => users_values, :label => :label_crm_has_open_issues
) unless users_values.empty?
add_custom_fields_filters(ContactCustomField.where(:is_filter => true))
add_associations_custom_fields_filters :author, :assigned_to
end
def available_columns
return @available_columns if @available_columns
@available_columns = self.class.available_columns.dup
@available_columns += CustomField.where(:type => 'ContactCustomField').all.map {|cf| QueryCustomFieldColumn.new(cf) }
@available_columns
end
def default_columns_names
@default_columns_names ||= [:id, :name, :job_title, :company, :phone, :email, :address]
end
def sql_for_tags_field(field, operator, value)
compare = operator_for('tags').eql?('=') ? 'IN' : 'NOT IN'
ids_list = Contact.tagged_with(value).collect{|contact| contact.id }.push(0).join(',')
"( #{Contact.table_name}.id #{compare} (#{ids_list}) ) "
end
def sql_for_project_field(field, operator, value)
'(' + sql_for_field(field, operator, value, Project.table_name, "id", false) + ')'
end
def sql_for_country_field(field, operator, value)
if operator == '*' # Any group
contact_countries = l(:label_crm_countries).map{|k, v| k.to_s}
operator = '=' # Override the operator since we want to find by assigned_to
elsif operator == "!*"
contact_countries = l(:label_crm_countries).map{|k, v| k.to_s}
operator = '!' # Override the operator since we want to find by assigned_to
else
contact_countries = value
end
'(' + sql_for_field("address_id", operator, contact_countries, Address.table_name, "country_code", false) + ')'
end
def sql_for_city_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "city")
end
def sql_for_street1_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "street1")
end
def sql_for_street2_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "street2")
end
def sql_for_full_address_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "full_address")
end
def sql_for_region_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "region")
end
def sql_for_postcode_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "postcode")
end
def sql_for_has_deals_field(field, operator, value)
db_table = Deal.table_name
if operator == "!"
"#{Contact.table_name}.id IN (
SELECT #{db_table}.contact_id FROM #{db_table}
GROUP BY #{db_table}.contact_id
HAVING COUNT(#{db_table}.id) = 0)"
else operator == "="
"#{Contact.table_name}.id IN (
SELECT #{db_table}.contact_id FROM #{db_table}
GROUP BY #{db_table}.contact_id
HAVING COUNT(#{db_table}.id) > 0)"
end
end
def sql_for_has_open_issues_field(field, operator, value)
db_table = ContactNote.table_name
if operator == "!*"
"#{Contact.table_name}.id IN (
SELECT #{Contact.table_name}.id FROM #{Contact.table_name}
LEFT JOIN contacts_issues ON contacts_issues.contact_id = #{Contact.table_name}.id
LEFT JOIN #{Issue.table_name} ON contacts_issues.issue_id = #{Issue.table_name}.id
LEFT JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id
WHERE (#{IssueStatus.table_name}.is_closed = #{ActiveRecord::Base.connection.quoted_false}) OR (#{IssueStatus.table_name}.is_closed IS NULL)
GROUP BY #{Contact.table_name}.id
HAVING COUNT(#{Issue.table_name}.id) = 0)"
elsif operator == "*"
"#{Contact.table_name}.id IN (
SELECT contacts_issues.contact_id FROM contacts_issues
INNER JOIN #{Issue.table_name} ON contacts_issues.issue_id = #{Issue.table_name}.id
INNER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id
WHERE #{IssueStatus.table_name}.is_closed = #{ActiveRecord::Base.connection.quoted_false}
GROUP BY contacts_issues.contact_id
HAVING COUNT(#{Issue.table_name}.id) > 0)"
else
"#{Contact.table_name}.id IN (
SELECT contacts_issues.contact_id FROM contacts_issues
INNER JOIN #{Issue.table_name} ON contacts_issues.issue_id = #{Issue.table_name}.id
INNER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id
WHERE #{IssueStatus.table_name}.is_closed = #{ActiveRecord::Base.connection.quoted_false}
AND #{sql_for_field("assigned_to_id", operator, value, Issue.table_name, 'assigned_to_id')}
GROUP BY contacts_issues.contact_id)"
end
end
def sql_for_last_note_field(field, operator, value)
db_table = ContactNote.table_name
if operator == "!*"
"#{Contact.table_name}.id IN (
SELECT #{Contact.table_name}.id FROM #{Contact.table_name}
LEFT JOIN #{db_table} ON #{db_table}.source_id = #{Contact.table_name}.id and #{db_table}.source_type = 'Contact'
GROUP BY #{Contact.table_name}.id
HAVING COUNT(#{db_table}.id) = 0)"
elsif operator == "*"
"#{Contact.table_name}.id IN (
SELECT #{Contact.table_name}.id FROM #{Contact.table_name}
INNER JOIN #{db_table} ON #{db_table}.source_id = #{Contact.table_name}.id and #{db_table}.source_type = 'Contact'
GROUP BY #{Contact.table_name}.id
HAVING COUNT(#{db_table}.id) > 0)"
else
"#{Contact.table_name}.id IN (
SELECT #{db_table}.source_id
FROM #{db_table}
WHERE #{db_table}.source_type='Contact'
AND #{db_table}.id IN
(SELECT MAX(#{db_table}.id)
FROM #{db_table}
WHERE #{db_table}.source_type='Contact'
GROUP BY #{db_table}.source_id)
AND #{sql_for_field(field, operator, value, db_table, 'created_on')}
)"
end
end
def objects_scope(options={})
scope = Contact.visible
options[:search].split(' ').collect{ |search_string| scope = scope.live_search(search_string) } unless options[:search].blank?
scope = scope.includes((query_includes + (options[:include] || [])).uniq).
where(statement).
where(options[:conditions])
scope
end
def query_includes
[:address, :projects, :assigned_to]
end
end
@@ -0,0 +1,36 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsIssue < ActiveRecord::Base
include Redmine::SafeAttributes
validates_presence_of :contact_id, :issue_id
validates_uniqueness_of :contact_id, :scope => [:issue_id]
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'issue_id', 'contact_id'
# after_create :send_mails
# after_save :send_mails
private
def send_mails
Mailer.deliver_contacts_issue_connected(Contact.find(contact_id), Issue.find(issue_id))
true
end
end
@@ -0,0 +1,262 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsMailer < ActionMailer::Base
include Redmine::I18n
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
helper :application
attr_reader :email, :user
def self.default_url_options
h = Setting.host_name
h = h.to_s.gsub(%r{\/.*$}, '') unless Redmine::Utils.relative_url_root.blank?
{ :host => h, :protocol => Setting.protocol }
end
def bulk_mail(contact, params = {})
raise l(:error_empty_email) if (contact.emails.empty? || params[:message].blank?)
@contact = contact
@params = params
params[:attachments].each_value do |mail_attachment|
if file = mail_attachment['file']
file.rewind if file
attachments[file.original_filename] = file.binread
file.rewind if file
elsif token = mail_attachment['token']
if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
attachment_id, attachment_digest = $1, $2
if a = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
attachments[a.filename] = File.binread(a.diskfile)
end
end
end
end unless params[:attachments].blank?
mail(:from => params[:from] || User.current.mail,
:to => contact.emails.first,
:cc => params[:cc],
:bcc => params[:bcc],
:subject => params[:subject]) do |format|
format.text
format.html
end
end
def self.receive(email, options={})
@@contacts_mailer_options = options.dup
super email
end
# Processes incoming emails
# Returns the created object (eg. an issue, a message) or false
def receive(email)
# debugger
@email = email
sender_email = email.from.to_a.first.to_s.strip
# Ignore emails received from the application emission address to avoid hell cycles
if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
logger.info "ContactsMailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
return false
end
@user = User.find_by_mail(sender_email) if sender_email.present?
if @user.nil? || (@user && !@user.active?)
logger.info "ContactsMailHandler: user not found [#{sender_email}]" if logger && logger.info
end
dispatch
end
def dispatch
deal_id = email.to.to_s.match(/.+\+d([0-9]*)/).to_a[1]
deal_id ||= email.bcc.to_s.match(/.+\+d([0-9]*)/).to_a[1]
deal_id ||= email.cc.to_s.match(/.+\+d([0-9]*)/).to_a[1]
if deal_id
deal = Deal.find_by_id(deal_id)
if deal
return [*receive_deal_note(deal_id)]
end
end
contacts = []
if contacts.blank?
contact_id = email.to.to_s.match(/.+\+c([0-9]*)/).to_a[1]
contact_id ||= email.bcc.to_s.match(/.+\+c([0-9]*)/).to_a[1]
contact_id ||= email.cc.to_s.match(/.+\+c([0-9]*)/).to_a[1]
contacts = Contact.where(:id => contact_id)
end
if contacts.blank?
contacts = Contact.find_by_emails(email.to.to_a)
end
if contacts.blank?
from_key_words = get_keyword_locales(:label_crm_mail_from)
@plain_text_body = plain_text_body.gsub(/^>\s*/, '').gsub('&gt; ','').gsub('&quot;', '"')
full_address = plain_text_body.match(/^(#{from_key_words.join('|')})[ \s]*:[ \s]*(.+)\s*$/).to_a[2]
email_address = full_address.match(/[\w,\.,\-,\+]+@.+\.\w{2,}/) if full_address
contacts = Contact.find_by_emails([email_address.to_s.strip]) if email_address
end
if contacts.blank?
return false
end
raise MissingInformation if contacts.blank?
result = []
contacts.each do |contact|
result << receive_contact_note(contact.id)
end
result
rescue ActiveRecord::RecordInvalid => e
# TODO: send a email to the user
logger.error e.message if logger
false
rescue MissingInformation => e
logger.error "ContactsMailHandler: missing information from #{user}: #{e.message}" if logger
false
rescue UnauthorizedAction => e
logger.error "ContactsMailHandler: unauthorized attempt from #{user}" if logger
false
end
# Receives a reply to a forum message
def receive_contact_note(contact_id)
contact = Contact.find_by_id(contact_id)
note = nil
# logger.error "ContactsMailHandler: receive_contact_note user: #{user},
# contact: #{contact.name},
# editable: #{contact.editable?(self.user)},
# current: #{User.current}"
raise UnauthorizedAction unless contact.editable?(self.user)
if contact
note = ContactNote.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
:type_id => Note.note_types[:email],
:content => plain_text_body,
:created_on => email.date)
note.author = self.user
contact.notes << note
add_attachments(note)
logger.info note
note.save
contact.save
end
note
end
def receive_deal_note(deal_id)
deal = Deal.find_by_id(deal_id)
note = nil
# logger.error "ContactsMailHandler: receive_contact_note user: #{user},
# contact: #{contact.name},
# editable: #{contact.editable?(self.user)},
# current: #{User.current}"
raise UnauthorizedAction unless deal.editable?(self.user)
if deal
note = DealNote.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
:type_id => Note.note_types[:email],
:content => plain_text_body,
:created_on => email.date)
note.author = self.user
deal.notes << note
add_attachments(note)
logger.info note
note.save
deal.save
end
note
end
private
# Destructively extracts the value for +attr+ in +text+
# Returns nil if no matching keyword found
def extract_keyword!(text, attr, format=nil)
keys = [attr.to_s.humanize]
if attr.is_a?(Symbol)
keys << l("field_#{attr}", :default => '', :locale => user.language) if user && user.language.present?
keys << l("field_#{attr}", :default => '', :locale => Setting.default_language) if Setting.default_language.present?
end
keys.reject! {|k| k.blank?}
keys.collect! {|k| Regexp.escape(k)}
format ||= '.+'
text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '') # /^(От:)[ \t]*:[ \t]*(.+)\s*$/i
$2 && $2.strip
end
def add_attachments(obj)
if email.attachments && email.attachments.any?
email.attachments.each do |attachment|
obj.attachments << Attachment.create(:container => obj,
:file => attachment.decoded,
:filename => attachment.filename,
:author => user,
:content_type => attachment.mime_type)
end
end
end
# Returns the text/plain part of the email
# If not found (eg. HTML-only email), returns the body with tags removed
def plain_text_body
return @plain_text_body unless @plain_text_body.nil?
part = email.text_part || email.html_part || email
@plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
# strip html tags and remove doctype directive
@plain_text_body = ActionController::Base.helpers.strip_tags(@plain_text_body.strip) unless email.text_part
@plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
@plain_text_body
end
def get_keyword_locales(keyword)
I18n.available_locales.collect{|lc| l(keyword, :locale => lc)}.uniq
end
# Appends a Redmine header field (name is prepended with 'X-Redmine-')
def redmine_headers(h)
h.each { |k,v| headers["X-Redmine-#{k}"] = v }
end
def initialize_defaults(method_name)
super
# Common headers
headers 'X-Mailer' => 'Redmine Contacts',
'X-Redmine-Host' => Setting.host_name,
'X-Redmine-Site' => Setting.app_title
end
def logger
Rails.logger
end
end
@@ -0,0 +1,171 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class ContactsSetting < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
TAX_TYPE_EXCLUSIVE = 1
TAX_TYPE_INCLUSIVE = 2
belongs_to :project
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'name', 'value', 'project_id'
cattr_accessor :settings
acts_as_attachable
# Hash used to cache setting values
@contacts_cached_settings = {}
@contacts_cached_cleared_on = Time.now
validates_uniqueness_of :name, :scope => [:project_id]
# Returns the value of the setting named name
def self.[](name, project_id)
project_id = project_id.id if project_id.is_a?(Project)
v = @contacts_cached_settings[hk(name, project_id)]
v ? v : (@contacts_cached_settings[hk(name, project_id)] = find_or_default(name, project_id).value)
end
def self.[]=(name, project_id, v)
project_id = project_id.id if project_id.is_a?(Project)
setting = find_or_default(name, project_id)
setting.value = (v ? v : '')
@contacts_cached_settings[hk(name, project_id)] = nil
setting.save
setting.value
end
# Checks if settings have changed since the values were read
# and clears the cache hash if it's the case
# Called once per request
def self.check_cache
settings_updated_on = ContactsSetting.maximum(:updated_on)
if settings_updated_on && @contacts_cached_cleared_on <= settings_updated_on
clear_cache
end
end
# Clears the settings cache
def self.clear_cache
@contacts_cached_settings.clear
@contacts_cached_cleared_on = Time.now
logger.info 'Contacts settings cache cleared.' if logger
end
def self.contact_name_format
Setting.plugin_redmine_contacts['name_format'] || :firstname_lastname
end
def self.vcard?
Object.const_defined?(:Vcard)
end
def self.spreadsheet?
Object.const_defined?(:Spreadsheet)
end
def self.monochrome_tags?
!!Setting.plugin_redmine_contacts['monochrome_tags']
end
def self.contacts_show_in_top_menu?
!!Setting.plugin_redmine_contacts['contacts_show_in_top_menu']
end
def self.contacts_show_in_app_menu?
!!Setting.plugin_redmine_contacts['contacts_show_in_app_menu']
end
def self.default_country
Setting.plugin_redmine_contacts['default_country']
end
def self.cross_project_contacts?
Setting.plugin_redmine_contacts['cross_project_contacts'].to_i > 0
end
# Finance
def self.default_currency
Setting.plugin_redmine_contacts['default_currency'] || 'USD'
end
def self.major_currencies
currencies = Setting.plugin_redmine_contacts['major_currencies'].to_s.split(',').select { |c| !c.blank? }.map(&:strip)
currencies = %w(USD EUR GBP RUB CHF) if currencies.blank?
currencies.compact.uniq
end
def self.default_tax
Setting.plugin_redmine_contacts['default_tax'].to_f
end
def self.tax_type
((['1', '2'] & [Setting.plugin_redmine_contacts['tax_type'].to_s]).first || TAX_TYPE_EXCLUSIVE).to_i
end
def self.tax_exclusive?
ContactsSetting.tax_type == TAX_TYPE_EXCLUSIVE
end
def self.thousands_delimiter
([' ', ',', '.'] & [Setting.plugin_redmine_contacts['thousands_delimiter']]).first || ' '
end
def self.decimal_separator
([',', '.'] & [Setting.plugin_redmine_contacts['decimal_separator']]).first || '.'
end
def self.disable_taxes?
!!Setting.plugin_redmine_contacts['disable_taxes']
end
def self.post_address_format
unless Setting.plugin_redmine_contacts['post_address_format'].blank?
Setting.plugin_redmine_contacts['post_address_format'].to_s.strip
else
"%street1%\n%street2%\n%city%, %postcode%\n%region%\n%country%"
end
end
def self.deals_show_in_top_menu?
!!Setting.plugin_redmine_contacts['deals_show_in_top_menu']
end
def self.deals_show_in_app_menu?
!!Setting.plugin_redmine_contacts['deals_show_in_app_menu']
end
private
def self.hk(name, project_id)
"#{name}-#{project_id.to_s}"
end
# Returns the Setting instance for the setting named name
# (record found in database or new record with default value)
def self.find_or_default(name, project_id)
name = name.to_s
setting = find_by_name_and_project_id(name, project_id)
setting ||= new(:name => name, :value => '', :project_id => project_id)
end
end
@@ -0,0 +1,244 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
module CrmQuery
def self.included(base)
base.send :include, InstanceMethods
base.extend ClassMethods
end
module ClassMethods
def visible(*args)
user = args.shift || User.current
base = Project.allowed_to_condition(user, "view_#{queried_class.name.pluralize.downcase}".to_sym, *args)
if Redmine::VERSION.to_s < '2.4'
user_id = user.logged? ? user.id : 0
return includes(:project).where("(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id)
end
scope = eager_load(:project).where("#{table_name}.project_id IS NULL OR (#{base})")
if user.admin?
scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", Query::VISIBILITY_PRIVATE, user.id)
elsif user.memberships.any?
scope.where("#{table_name}.visibility = ?" +
" OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
"SELECT DISTINCT q.id FROM #{table_name} q" +
" INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
" INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
" INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
" WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
" OR #{table_name}.user_id = ?",
Query::VISIBILITY_PUBLIC, Query::VISIBILITY_ROLES, user.id, user.id)
elsif user.logged?
scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", Query::VISIBILITY_PUBLIC, user.id)
else
scope.where("#{table_name}.visibility = ?", Query::VISIBILITY_PUBLIC)
end
end
end
module InstanceMethods
def visible?(user=User.current)
return true if user.admin?
return false unless project.nil? || user.allowed_to?("view_#{queried_class.name.pluralize.downcase}".to_sym, project)
case visibility
when Query::VISIBILITY_PUBLIC
true
when Query::VISIBILITY_ROLES
if project
(user.roles_for_project(project) & roles).any?
else
Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
end
else
user == self.user
end
end
def is_private?
visibility == Query::VISIBILITY_PRIVATE
end
def is_public?
!is_private?
end
def initialize_project_filter(position=nil)
if project.blank?
project_values = []
if User.current.logged? && User.current.memberships.any?
project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
end
project_values += all_projects_values
add_available_filter("project_id", :order => position,
:type => :list, :values => project_values
) unless project_values.empty?
end
end
def initialize_author_filter(position=nil)
add_available_filter("author_id", :order => position,
:type => :list_optional, :values => users_values
) unless users_values.empty?
end
def initialize_assignee_filter(position=nil)
add_available_filter("assigned_to_id", :order => position,
:type => :list_optional, :values => users_values
) unless users_values.empty?
end
def initialize_contact_country_filter(position=nil)
contact_countries = l(:label_crm_countries).map{|k, v| [v, k]}
add_available_filter("contact_country", :order => position,
:type => :list_optional, :values => contact_countries, :label => :label_crm_contact_country
) unless contact_countries.empty?
end
def initialize_contact_city_filter(position=nil)
add_available_filter("contact_city", :order => position,
:type => :string, :label => :label_crm_contact_city
)
end
def sql_for_contact_country_field(field, operator, value)
if operator == '*' # Any group
contact_countries = l(:label_crm_countries).map{|k, v| k.to_s}
operator = '=' # Override the operator since we want to find by assigned_to
elsif operator == "!*"
contact_countries = l(:label_crm_countries).map{|k, v| k.to_s}
operator = '!' # Override the operator since we want to find by assigned_to
else
contact_countries = value
end
'(' + sql_for_field("address_id", operator, contact_countries, Address.table_name, "country_code", false) + ')'
end
def sql_for_contact_city_field(field, operator, value)
sql_for_field(field, operator, value, Address.table_name, "city")
end
def sql_for_ids_field(field, operator, value)
if operator == "*"
"1=1"
elsif operator == "="
ids = value.first.to_s.scan(/\d+/).map(&:to_i).join(",")
if ids.present?
"#{self.queried_class.table_name}.id IN (#{ids})"
else
"1=0"
end
elsif operator == ">="
id = value.first.to_s.scan(/\d+/).map(&:to_i).first
if id.present?
"#{self.queried_class.table_name}.id >= (#{id})"
else
"1=0"
end
elsif operator == "<="
id = value.first.to_s.scan(/\d+/).map(&:to_i).first
if id.present?
"#{self.queried_class.table_name}.id <= (#{id})"
else
"1=0"
end
elsif operator == "><"
if value.is_a? Array
"#{self.queried_class.table_name}.id BETWEEN #{value.first} AND #{value.last}"
else
"1=0"
end
else
"1=0"
end
end if Redmine::VERSION.to_s >= '3.3'
def principals
return @principals if @principals
@principals = []
if project
@principals += project.principals.sort
unless project.leaf?
subprojects = project.descendants.visible.all
@principals += Principal.member_of(subprojects)
end
else
if all_projects.any?
@principals += Principal.member_of(all_projects)
end
end
@principals.uniq!
@principals.sort!
end
def users_values
return @users_values if @users_values
users = principals.select {|p| p.is_a?(User)}
@users_values = []
@users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
@users_values += users.collect{|s| [s.name, s.id.to_s] }
@users_values
end
def object_count
objects_scope.count
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def object_count_by_group
r = nil
if grouped?
begin
# Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
r = objects_scope.
joins(joins_for_order_statement(group_by_statement)).
group(group_by_statement).count
rescue ActiveRecord::RecordNotFound
r = {nil => object_count}
end
c = group_by_column
if c.is_a?(QueryCustomFieldColumn)
r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
end
end
r
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def objects_scope(options={})
raise NotImplementedError.new("You must implement #{name}.")
end
def results_scope(options={})
order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
objects_scope(options).
order(order_option).
joins(joins_for_order_statement(order_option.join(','))).
limit(options[:limit]).
offset(options[:offset])
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
end
end
+306
View File
@@ -0,0 +1,306 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class Deal < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
belongs_to :project
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
belongs_to :category, :class_name => 'DealCategory', :foreign_key => 'category_id'
belongs_to :contact
belongs_to :status, :class_name => 'DealStatus', :foreign_key => 'status_id'
has_many :deals, :class_name => 'Deal', :foreign_key => 'reference_id'
has_many :notes, :as => :source, :class_name => 'DealNote', :dependent => :delete_all
has_many :deal_processes, :dependent => :delete_all
has_many :deals_issues, :dependent => :destroy
has_many :issues, :through => :deals_issues
if Redmine::Plugin.load && Redmine::Plugin.installed?(:redmine_products) && Redmine::Plugin.find(:redmine_products).version >= '2.0.2'
has_many :lines, :class_name => 'ProductLine', :as => :container, :dependent => :delete_all
has_many :products, :through => :lines, :uniq => true, :select => "#{Product.table_name}.*, #{ProductLine.table_name}.position"
accepts_nested_attributes_for :lines, :allow_destroy => true
safe_attributes 'lines_attributes'
acts_as_priceable :amount, :tax_amount, :subtotal, :total
before_validation :assign_lines
before_save :calculate_price
end
if ActiveRecord::VERSION::MAJOR >= 4
has_and_belongs_to_many :related_contacts, lambda { order("#{Contact.table_name}.last_name, #{Contact.table_name}.first_name") }, :uniq => true, :class_name => 'Contact'
else
has_and_belongs_to_many :related_contacts, :order => "#{Contact.table_name}.last_name, #{Contact.table_name}.first_name", :uniq => true, :class_name => 'Contact'
end
scope :visible, lambda {|*args|
joins(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_deals, *args))
}
scope :by_project, lambda { |project_id| where(:project_id => project_id) unless project_id.blank? }
scope :deletable, lambda { |*args| joins(:project).where(Project.allowed_to_condition(args.first || User.current, :delete_deals)) }
scope :live_search, lambda { |search| where("(#{Deal.table_name}.name LIKE ?)", "%#{search}%") }
scope :live_search_with_contact, ->(search) do
conditions = []
values = {}
search.split(' ').each_with_index { |word, index|
key = :"v#{index}"
conditions << "LOWER(#{Deal.table_name}.name) LIKE LOWER(:#{key})"
conditions << "LOWER(#{Contact.table_name}.first_name) LIKE LOWER(:#{key})"
conditions << "LOWER(#{Contact.table_name}.last_name) LIKE LOWER(:#{key})"
conditions << "LOWER(#{Contact.table_name}.company) LIKE LOWER(:#{key})"
conditions << "LOWER(#{Contact.table_name}.email) LIKE LOWER(:#{key})"
values[key] = "%#{word}%"
}
sql = conditions.join(' OR ')
joins(:contact).where(sql, values)
end
scope :open, lambda { joins(:status).where("(#{DealStatus.table_name}.status_type = ? OR #{DealStatus.table_name}.status_type IS NULL)", DealStatus::OPEN_STATUS) }
scope :closed, lambda { joins(:status).where("#{DealStatus.table_name}.status_type <> ?", DealStatus::OPEN_STATUS) }
scope :won, lambda { joins(:status).where("#{DealStatus.table_name}.status_type = ?", DealStatus::WON_STATUS) }
scope :lost, lambda { joins(:status).where("#{DealStatus.table_name}.status_type = ?", DealStatus::LOST_STATUS) }
scope :was_in_status, lambda { |status_id| joins(:deal_processes).where(["#{DealProcess.table_name}.old_value = ? OR #{DealProcess.table_name}.value = ?", status_id, status_id]).uniq }
scope :with_status, lambda { |status_id| where(:status_id => status_id) }
acts_as_priceable :price, :expected_revenue
acts_as_customizable
acts_as_viewable
acts_as_watchable
acts_as_attachable :view_permission => :view_deals,
:delete_permission => :edit_deals
acts_as_event :datetime => :created_on,
:url => Proc.new { |o| { :controller => 'deals', :action => 'show', :id => o } },
:type => 'icon icon-add-deal',
:title => Proc.new { |o| o.name },
:description => Proc.new { |o| [o.price_to_s, o.contact ? o.contact.name : nil, o.background].join(' ').strip }
if ActiveRecord::VERSION::MAJOR >= 4
acts_as_activity_provider :type => 'deals',
:permission => :view_deals,
:author_key => :author_id,
:scope => joins(:project)
acts_as_searchable :columns => ["#{table_name}.name",
"#{table_name}.background",
"#{DealNote.table_name}.content"],
:scope => includes([:project, :notes]),
:date_column => :created_on
else
acts_as_activity_provider :type => 'deals',
:permission => :view_deals,
:author_key => :author_id,
:find_options => { :include => :project }
acts_as_searchable :columns => ["#{table_name}.name",
"#{table_name}.background",
"#{DealNote.table_name}.content"],
:include => [:project, :notes],
:order_column => "#{table_name}.id"
end
validates_presence_of :name, :project, :status
validates_numericality_of :price, :allow_nil => true
after_update :create_deal_process
after_create :send_notification
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'name',
'background',
'currency',
'price',
'price_type',
'duration',
'project_id',
'author_id',
'assigned_to_id',
'status_id',
'contact_id',
'category_id',
'probability',
'due_date',
'custom_field_values',
'custom_fields',
'watcher_user_ids',
:if => lambda { |deal, user| deal.new_record? || user.allowed_to?(:edit_deals, deal.project) }
def initialize(attributes = nil, *args)
super
return unless new_record?
# set default values for new records only
self.status_id = DealStatus.default.try(:id)
self.currency ||= ContactsSetting.default_currency
end
def avatar
end
def expected_revenue
probability ? (probability.to_f / 100) * price.to_f : price
end
def full_name
result = ''
result << contact.name + ': ' unless contact.blank?
result << name
end
def all_contacts
@all_contacts ||= ([contact] + related_contacts).uniq
end
def self.available_users(prj = nil)
cond = '(1=1)'
cond << " AND #{Deal.table_name}.project_id = #{prj.id}" if prj
User.active.select("DISTINCT #{User.table_name}.*").
joins("JOIN #{Deal.table_name} ON #{Deal.table_name}.assigned_to_id = #{User.table_name}.id").
where(cond).
order("#{User.table_name}.lastname, #{User.table_name}.firstname")
end
def open?
status.blank? || status.is_open?
end
def init_deal_process(author)
@current_deal_process ||= DealProcess.new(:deal => self, :author => (author || User.current))
@deal_status_before_change = new_record? ? nil : status_id
@current_deal_process
end
def create_deal_process
if @current_deal_process && @deal_status_before_change && !(@deal_status_before_change == status_id)
@current_deal_process.old_value = @deal_status_before_change
@current_deal_process.value = status_id
@current_deal_process.save
init_deal_process @current_deal_process.author
end
end
def visible?(usr = nil)
(usr || User.current).allowed_to?(:view_deals, project)
end
def editable?(usr = nil)
(usr || User.current).allowed_to?(:edit_deals, project)
end
def destroyable?(usr = nil)
(usr || User.current).allowed_to?(:delete_deals, project)
end
# Returns an array of projects that user can move deal to
def self.allowed_target_projects(user = User.current)
Project.where(Project.allowed_to_condition(user, :add_deals))
end
# Returns the mail adresses of users that should be notified
def recipients
notified = []
# Author and assignee are always notified unless they have been
# locked or don't want to be notified
notified << author if author
if assigned_to
notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
end
notified += project.notified_users
notified = notified.select { |u| u.active? }
notified.uniq!
# Remove users that can not view the contact
notified.reject! { |user| !visible?(user) }
notified.collect(&:mail)
end
def status_was
if status_id_changed? && status_id_was.present?
@status_was ||= DealStatus.find_by_id(status_id_was)
end
end
def copy_from(arg)
deal = arg.is_a?(Deal) ? arg : Deal.visible.find(arg)
self.attributes = deal.attributes.dup.except('id', 'created_at', 'updated_at')
self.custom_field_values = deal.custom_field_values.inject({}) { |h, v| h[v.custom_field_id] = v.value ; h }
if Redmine::Plugin.load && Redmine::Plugin.installed?(:redmine_products) && Redmine::Plugin.find(:redmine_products).version >= '2.0.2'
deal.lines.each do |line|
lines.build(line.attributes)
end
end
self
end
def contact_country
try(:contact).try(:address).try(:country)
end
def contact_city
try(:contact).try(:address).try(:city)
end
if Redmine::Plugin.load && Redmine::Plugin.installed?(:redmine_products) && Redmine::Plugin.find(:redmine_products).version >= '2.0.2'
def has_taxes?
!lines.map(&:tax).all? { |t| t == 0 || t.blank? }
end
def has_discounts?
!lines.map(&:discount).all? { |t| t == 0 || t.blank? }
end
def tax_amount
lines.select { |l| !l.marked_for_destruction? }.inject(0) { |sum, l| sum + l.tax_amount }.to_f
end
def subtotal
lines.select { |l| !l.marked_for_destruction? }.inject(0) { |sum, l| sum + l.total }.to_f
end
def total_units
lines.inject(0) { |sum, l| sum + (l.product.blank? ? 0 : l.quantity) }
end
def calculate_price
return true if lines.select { |l| !l.marked_for_destruction? }.empty?
self.price = subtotal + (ContactsSetting.tax_exclusive? ? tax_amount : 0)
end
end
def info
result = ''
result = status.name if status
result = result + ' - ' + price_to_s unless price.blank?
result.html_safe
end
private
def send_notification
Mailer.crm_deal_add(self).deliver if Setting.notified_events.include?('crm_deal_added')
end
if Redmine::Plugin.load && Redmine::Plugin.installed?(:redmine_products) && Redmine::Plugin.find(:redmine_products).version >= '2.0.2'
def assign_lines
return unless new_record?
lines.each { |l| l.container = self }
end
end
end
@@ -0,0 +1,52 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealCategory < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'name'
belongs_to :project
has_many :deals, :class_name => 'Deal', :foreign_key => 'category_id', :dependent => :nullify
validates_presence_of :name, :project
validates_uniqueness_of :name, :scope => [:project_id]
validates_length_of :name, :maximum => 30
alias :destroy_without_reassign :destroy
# Destroy the category
# If a category is specified, issues are reassigned to this category
def destroy(reassign_to = nil)
if reassign_to && reassign_to.is_a?(DealCategory) && reassign_to.project == self.project
if ActiveRecord::VERSION::MAJOR >= 4
Deal.where(:category_id => id).update_all(:category_id => reassign_to.id)
else
Deal.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
end
end
destroy_without_reassign
end
def <=>(category)
name <=> category.name
end
def to_s; name end
end
@@ -0,0 +1,26 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealCustomField < CustomField
unloadable
def type_name
:label_deal_plural
end
end
@@ -0,0 +1,46 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealImport
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
include CSVImportable
attr_accessor :file, :project, :quotes_type
def klass
Deal
end
def build_from_fcsv_row(row)
ret = Hash[row.to_hash.collect { |k, v| [k.underscore.tr(' ', '_'), force_utf8(v)] if k }].delete_if { |k, _v| !klass.column_names.include?(k) }
ret[:due_date] = row['due date'].to_date if row['due date']
ret[:status_id] = DealStatus.where(:name => row['status']).first.try(:id) if row['status']
ret[:category_id] = DealCategory.where(:name => row['category']).first.try(:id) if row['category']
ret[:assigned_to_id] = User.find_by_login(row['assignee']).try(:id) unless row['assignee'].blank?
ret[:price] = row['sum'].to_f if row['sum']
if row['contact'].to_s.match(/^\#(\d+):/)
ret[:contact_id] = Contact.find_by_id($1).try(:id)
end
ret
end
end
@@ -0,0 +1,101 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealKernelImport < Import
def klass
Deal
end
def saved_objects
object_ids = saved_items.pluck(:obj_id)
Deal.where(:id => object_ids).order(:id)
end
def project=(project)
settings['project'] = project.id
end
def project
settings['project']
end
private
def build_object(row, _item = nil)
deal = Deal.new
deal.project = Project.find(settings['project'])
deal.author = user
attributes = {}
if name = row_value(row, 'name')
attributes['name'] = name
end
if background = row_value(row, 'background')
attributes['background'] = background
end
if currency = row_value(row, 'currency')
attributes['currency'] = currency
end
if price = row_value(row, 'price')
attributes['price'] = price.to_f
end
if probability = row_value(row, 'probability')
attributes['probability'] = probability.to_i
end
if status = row_value(row, 'status')
attributes['status_id'] = DealStatus.where('name = ?', status).first.try(:id)
end
if contact = row_value(row, 'contact')
attributes['contact_id'] = Contact.by_full_name(contact).first.try(:id)
end
if assigned_to = row_value(row, 'assigned_to')
attributes['assigned_to_id'] = User.where("LOWER(CONCAT(#{User.table_name}.firstname,' ',#{User.table_name}.lastname)) = ? ", assigned_to.mb_chars.downcase.to_s)
.first
.try(:id)
end
if category = row_value(row, 'category')
attributes['category_id'] = DealCategory.where(:name => category).first.try(:id)
end
attributes['custom_field_values'] = deal.custom_field_values.inject({}) do |h, v|
value = case v.custom_field.field_format
when 'date'
row_date(row, "cf_#{v.custom_field.id}")
when 'list'
row_value(row, "cf_#{v.custom_field.id}").try(:split, ',')
else
row_value(row, "cf_#{v.custom_field.id}")
end
if value
h[v.custom_field.id.to_s] =
if value.is_a?(Array)
value.map { |val| v.custom_field.value_from_keyword(val.strip, contact) }.compact.flatten
else
v.custom_field.value_from_keyword(value, contact)
end
end
h
end
deal.send :safe_attributes=, attributes, user
deal
end
end
@@ -0,0 +1,51 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealNote < Note
unloadable
include Redmine::SafeAttributes
belongs_to :deal, :foreign_key => :source_id
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'subject', 'type_id', 'content'
if ActiveRecord::VERSION::MAJOR >= 4
if ActiveRecord::Base.connection.table_exists?('notes')
acts_as_activity_provider :type => 'deals',
:permission => :view_deals,
:author_key => :author_id,
:scope => joins(:deal => :project).where(:source_type => 'Deal')
end
else
acts_as_activity_provider :type => 'deals',
:permission => :view_deals,
:author_key => :author_id,
:find_options => { :joins => [:deal => :project],
:conditions => { :source_type => 'Deal' } }
end
scope :visible, lambda {|*args| joins(:deal => :project).
where(Project.allowed_to_condition(args.first || User.current, :view_deals) +
" AND (#{DealNote.table_name}.source_type = 'Deal')") }
acts_as_attachable :view_permission => :view_deals,
:delete_permission => :edit_deals
def custom_field_values
Note.new.custom_field_values
end
end
@@ -0,0 +1,44 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealProcess < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'deal', 'author'
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
belongs_to :deal
belongs_to :from, :class_name => 'DealStatus', :foreign_key => 'old_value'
belongs_to :to, :class_name => 'DealStatus', :foreign_key => 'value'
scope :visible, lambda { |*args| joins(:deal => :project).where(Project.allowed_to_condition(args.first || User.current, :view_deals)) }
after_create :send_notification
def recipients
(deal.recipients + [author.mail]).uniq
end
private
def send_notification
Mailer.crm_deal_updated(self).deliver if Setting.notified_events.include?('crm_deal_updated')
end
end
@@ -0,0 +1,178 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealQuery < Query
include CrmQuery
include RedmineCrm::MoneyHelper
self.queried_class = Deal
self.view_permission = :view_deals if Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch?
self.available_columns = [
QueryColumn.new(:name, :sortable => "#{Deal.table_name}.name", :caption => :field_deal_name),
QueryColumn.new(:price, :sortable => ["#{Deal.table_name}.currency", "#{Deal.table_name}.price"], :default_order => 'desc', :caption => :field_price),
QueryColumn.new(:status, :sortable => "#{Deal.table_name}.status_id", :groupable => true, :caption => :field_contact_status),
QueryColumn.new(:currency, :sortable => "#{Deal.table_name}.currency", :groupable => true, :caption => :field_currency),
QueryColumn.new(:contact, :sortable => lambda { Contact.fields_for_order_statement }, :groupable => true, :caption => :label_contact),
QueryColumn.new(:category, :sortable => "#{Deal.table_name}.category_id", :groupable => true),
QueryColumn.new(:probability, :sortable => "#{Deal.table_name}.probability", :groupable => "#{Deal.table_name}.probability", :caption => :label_crm_probability),
QueryColumn.new(:expected_revenue, :sortable => ["#{Deal.table_name}.currency", "#{Deal.table_name}.price * (#{Deal.table_name}.probability / 100)"], :caption => :label_crm_expected_revenue),
QueryColumn.new(:contact_city, :caption => :label_crm_contact_city, :groupable => "#{Address.table_name}.city", :sortable => "#{Address.table_name}.city"),
QueryColumn.new(:contact_country, :caption => :label_crm_contact_country, :groupable => "#{Address.table_name}.country_code", :sortable => "#{Address.table_name}.country_code"),
QueryColumn.new(:due_date, :sortable => "#{Deal.table_name}.due_date"),
QueryColumn.new(:due_date, :sortable => "#{Deal.table_name}.due_date"),
QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
QueryColumn.new(:created_on, :sortable => "#{Deal.table_name}.created_on"),
QueryColumn.new(:updated_on, :sortable => "#{Deal.table_name}.updated_on"),
QueryColumn.new(:assigned_to, :sortable => lambda { User.fields_for_order_statement }, :groupable => true),
QueryColumn.new(:author, :sortable => lambda { User.fields_for_order_statement('authors') }),
QueryColumn.new(:background)
]
def initialize(attributes = nil, *args)
super attributes
self.filters ||= { 'status_id' => { :operator => 'o', :values => [''] } }
end
def initialize_available_filters
add_available_filter 'ids', :type => :integer, :label => :label_deal if Redmine::VERSION.to_s >= '3.3'
add_available_filter 'price', :type => :float, :label => :field_price
add_available_filter 'currency', :type => :list,
:label => :field_currency,
:values => collection_for_currencies_select(ContactsSetting.default_currency, ContactsSetting.major_currencies)
add_available_filter 'background', :type => :text, :label => :field_background
add_available_filter 'due_date', :type => :date, :order => 20
add_available_filter 'updated_on', :type => :date_past, :order => 20
add_available_filter 'created_on', :type => :date, :order => 21
add_available_filter 'probability', :type => :float, :label => :label_crm_probability
deal_statuses = (project.blank? ? DealStatus.order("#{DealStatus.table_name}.status_type, #{DealStatus.table_name}.position") : project.deal_statuses) || []
add_available_filter('status_id',
:type => :list_status, :values => deal_statuses.map { |a| [a.name, a.id.to_s] }, :label => :field_contact_status, :order => 1
) unless deal_statuses.empty?
initialize_project_filter
initialize_author_filter
initialize_assignee_filter
initialize_contact_country_filter
initialize_contact_city_filter
add_custom_fields_filters(DealCustomField.where(:is_filter => true))
add_associations_custom_fields_filters :contact, :notes, :author, :assigned_to
if RedmineContacts.products_plugin_installed?
products = Product.visible.all
add_available_filter('products', :type => :list_optional,
:values => products.map { |a| [a.name, a.id.to_s] }, :label => :label_product_plural
) unless products.empty?
product_categories = []
ProductCategory.category_tree(ProductCategory.order(:lft)) do |product_category, level|
name_prefix = (level > 0 ? '-' * 2 * level + ' ' : '').html_safe
product_categories << [(name_prefix + product_category.name).html_safe, product_category.id.to_s]
end
add_available_filter('product_category_id', :type => :list,
:label => :label_products_category_filter,
:values => product_categories
) if product_categories.any?
add_associations_custom_fields_filters :products, :lines
end
end
def available_columns
return @available_columns if @available_columns
@available_columns = self.class.available_columns.dup
@available_columns += CustomField.where(:type => 'DealCustomField').all.map { |cf| QueryCustomFieldColumn.new(cf) }
@available_columns += CustomField.where(:type => 'ContactCustomField').all.map { |cf| QueryAssociationCustomFieldColumn.new(:contact, cf) }
@available_columns << QueryColumn.new(:products, :caption => :label_product_plural) if RedmineContacts.products_plugin_installed?
@available_columns
end
def default_columns_names
@default_columns_names ||= [:id, :name, :contact, :price]
end
if RedmineContacts.products_plugin_installed?
def sql_for_products_field(_field, operator, value)
if operator == '*'
products = Product.visible.all
operator = '='
elsif operator == '!*'
products = Product.visible.all
operator = '!'
else
products = Product.visible.where(:id => value)
end
products ||= []
order_products = products.map(&:id).uniq.compact.sort.collect(&:to_s)
'(' + sql_for_field('product_id', operator, order_products, ProductLine.table_name, 'product_id', false) + ')'
end
def sql_for_product_category_id_field(field, operator, value)
category_ids = value
category_ids += ProductCategory.where(:id => value).map(&:descendants).flatten.collect { |c| c.id.to_s }.uniq
sql_for_field(field, operator, category_ids, Product.table_name, 'category_id')
end
end
def sql_for_status_id_field(field, operator, value)
sql = ''
case operator
when "o"
sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{DealStatus.table_name} WHERE status_type = #{DealStatus::OPEN_STATUS})" if field == "status_id"
when "c"
sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{DealStatus.table_name} WHERE status_type IN (#{DealStatus::WON_STATUS}, #{DealStatus::LOST_STATUS}))" if field == "status_id"
else
sql_for_field(field, operator, value, queried_table_name, field)
end
end
def deal_amount
@deal_amount ||= objects_scope.group("#{Deal.table_name}.currency").sum(:price)
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def weighted_amount
@weighted_amount ||= objects_scope.open.group("#{Deal.table_name}.currency").sum("#{Deal.table_name}.price * #{Deal.table_name}.probability / 100")
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def objects_scope(options={})
scope = Deal.visible
options[:search].split(' ').collect{ |search_string| scope = scope.live_search(search_string) } unless options[:search].blank?
scope = scope.includes((query_includes + (options[:include] || [])).uniq).
where(statement).
where(options[:conditions])
scope
end
def query_includes
includes = [:status, :project]
includes << { :contact => :address } if self.filters['contact_country'] ||
self.filters['contact_city'] ||
[:contact_country, :contact_city].include?(group_by_column.try(:name))
includes << :assigned_to if self.filters['assigned_to_id'] || (group_by_column && [:assigned_to].include?(group_by_column.name))
if RedmineContacts.products_plugin_installed?
includes << :products if filters['products']
includes << :products if filters['product_category_id']
end
includes
end
end
@@ -0,0 +1,116 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealStatus < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
OPEN_STATUS = 0
WON_STATUS = 1
LOST_STATUS = 2
before_destroy :check_integrity
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'name', 'is_default', 'status_type', 'move_to', 'color_name', 'position'
has_and_belongs_to_many :projects
has_many :deals, :foreign_key => 'status_id', :dependent => :nullify
has_many :deal_processes_from, :class_name => 'DealProcess',:foreign_key => 'old_value', :dependent => :delete_all
has_many :deal_processes_to, :class_name => 'DealProcess', :foreign_key => 'value', :dependent => :delete_all
rcrm_acts_as_list :scope => 'status_type = #{status_type}'
scope :open, lambda { where(:status_type => DealStatus::OPEN_STATUS) }
scope :won, lambda { where(:status_type => DealStatus::WON_STATUS) }
scope :lost, lambda { where(:status_type => DealStatus::LOST_STATUS) }
scope :closed, lambda { where("#{DealStatus.table_name}.status_type <> #{DealStatus::OPEN_STATUS}") }
after_save :update_default
validates_presence_of :name
validates_uniqueness_of :name
validates_length_of :name, :maximum => 30
def update_default
DealStatus.where('id <> ?', id).update_all(:is_default => false) if is_default?
end
# Returns the default status for new Deals
def self.default
where(:is_default => true).first
end
def is_open?
status_type == OPEN_STATUS
end
def is_won?
status_type == WON_STATUS
end
def is_lost?
status_type == LOST_STATUS
end
def is_closed?
!is_open?
end
def status_type_name
case status_type
when OPEN_STATUS then l(:label_open_issues)
when WON_STATUS then l(:label_crm_deal_status_won)
when LOST_STATUS then l(:label_crm_deal_status_lost)
else ''
end
end
def new_status_allowed_to?(status, roles, tracker)
if status && roles && tracker
!workflows.where(:new_status_id => status.id).where(:role_id => roles.collect(&:id)).where(:tracker_id => tracker.id).first.nil?
else
false
end
end
def color_name
return '#' + "%06x" % color unless color.nil?
end
def color_name=(clr)
self.color = clr.from(1).hex
end
def <=>(status)
position <=> status.position
end
def to_s; name end
private
def check_integrity
raise "Can't delete status" if Deal.where(:status_id => id).any?
end
# Deletes associated workflows
def delete_workflows
Workflow.delete_all(['old_status_id = :id OR new_status_id = :id', { :id => id }])
end
end
@@ -0,0 +1,33 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealsIssue < ActiveRecord::Base
include Redmine::SafeAttributes
belongs_to :issue
belongs_to :deal
validate :validate_deals_issue
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'issue_id', 'deal_id', 'issue', 'deal'
def validate_deals_issue
errors.add :deal_id, :invalid if deal_id && !deal
end
end
@@ -0,0 +1,79 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class DealsPipelineProcessor
attr_reader :scope
def initialize(scope)
@scope = scope
end
def count
@scope.count
end
def deals_for_status(status)
if status.is_open?
open_deals_for_status(status) + closed_deals_for_status(status)
else
@scope.where(:status_id => status.id)
end
end
def closed_deals_for_status(status)
deal_status_ids = DealStatus.open.where('position >= ?', status.position).pluck(:id)
first_condition = []
second_condition = []
if lost_status_ids.present?
first_condition << "dp.value IN (#{lost_status_ids.join(',')})"
second_condition << "dp2.value IN (#{lost_status_ids.join(',')})"
end
if won_status_ids.present?
first_condition << "dp.old_value IN (#{won_status_ids.join(',')})"
second_condition << "dp2.old_value IN (#{won_status_ids.join(',')})"
end
first_sql = first_condition.present? ? "NOT (#{first_condition.join(' AND ')})" : '1=1'
second_sql = second_condition.present? ? "NOT (#{second_condition.join(' AND ')})" : '1=1'
ret = @scope.closed.joins("LEFT OUTER JOIN #{DealProcess.table_name} dp on dp.deal_id = deals.id AND #{first_sql}").
joins("LEFT OUTER JOIN #{DealProcess.table_name} dp2 ON (deals.id = dp2.deal_id AND (dp.created_at < dp2.created_at OR dp.created_at = dp2.created_at AND dp.id < dp2.id)) AND #{second_sql}").
joins("LEFT OUTER JOIN #{DealStatus.table_name} ds ON (ds.id = deals.status_id)").
where(['ds.status_type IN (?)', [DealStatus::WON_STATUS, DealStatus::LOST_STATUS] ]).
where("dp2.id IS NULL")
if status.is_open?
ret.where(["(dp.old_value IN (?) OR (#{Deal.table_name}.status_id IN (?)))", deal_status_ids, won_status_ids])
else
ret.where(["dp.old_value IN (?)", deal_status_ids])
end
end
def open_deals_for_status(status)
deal_status_ids = DealStatus.open.where('position >= ?', status.position).pluck(:id)
@scope.open.joins("LEFT OUTER JOIN #{DealStatus.table_name} ds ON (ds.id = deals.status_id)").
where(['ds.status_type NOT IN (?)', [DealStatus::WON_STATUS, DealStatus::LOST_STATUS] ]).
where(["#{Deal.table_name}.status_id IN (?)", deal_status_ids])
end
def won_status_ids
@won_status_ids ||= DealStatus.won.pluck(:id)
end
def lost_status_ids
@lost_status_ids ||= DealStatus.lost.pluck(:id)
end
end
@@ -0,0 +1,95 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class Note < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
belongs_to :source, :polymorphic => true, :touch => true
# added as a quick fix to allow eager loading of the polymorphic association for multiprojects
validates_presence_of :source, :author, :content
acts_as_customizable
acts_as_attachable
acts_as_event :title => Proc.new {|o| "#{l(:label_crm_note_for)}: #{o.source.name}"},
:type => "icon issue-note icon-issue-note",
:group => :source,
:url => Proc.new {|o| {:controller => 'notes', :action => 'show', :id => o.id }},
:description => Proc.new {|o| o.content}
after_create :send_notification
cattr_accessor :note_types
@@note_types = {:email => 0, :call => 1, :meeting => 2}
cattr_accessor :cut_length
@@cut_length = 1000
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'subject', 'type_id', 'author_id', 'note_time', 'content', 'created_on', 'custom_field_values'
def self.note_types
@@note_types
end
def note_time
self.created_on.to_s(:time) unless self.created_on.blank?
end
def note_time=(val)
if !self.created_on.blank? && val.to_s.gsub(/\s/, "").match(/^(\d{1,2}):(\d{1,2})$/)
self.created_on = self.created_on.change({:hour => $1.to_i % 24, :min => $2.to_i % 60})
end
end
def visible?(usr=nil)
self.source.visible?(usr)
end
def project
self.source.respond_to?(:project) ? self.source.project : nil
end
def editable_by?(usr, prj=nil)
prj ||= @project || self.project
usr && (usr.allowed_to?(:delete_notes, prj) || (self.author == usr && usr.allowed_to?(:delete_own_notes, prj)))
# usr && usr.logged? && (usr.allowed_to?(:edit_notes, project) || (self.author == usr && usr.allowed_to?(:edit_own_notes, project)))
end
def destroyable_by?(usr, prj=nil)
prj ||= @project || self.project
usr && (usr.allowed_to?(:delete_notes, prj) || (self.author == usr && usr.allowed_to?(:delete_own_notes, prj)))
end
def created_on
return nil if super.blank?
zone = User.current.time_zone
zone ? super.in_time_zone(zone) : (super.utc? ? super.localtime : super)
end
private
def send_notification
Mailer.crm_note_add(self).deliver if Setting.notified_events.include?('crm_note_added')
end
end
@@ -0,0 +1,26 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class NoteCustomField < CustomField
unloadable
def type_name
:label_crm_note_plural
end
end
@@ -0,0 +1,45 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class RecentlyViewed < ActiveRecord::Base
unloadable
include Redmine::SafeAttributes
attr_protected :id if ActiveRecord::VERSION::MAJOR <= 4
safe_attributes 'viewer'
RECENTLY_VIEWED_LIMIT = 5
belongs_to :viewer, :class_name => 'User', :foreign_key => 'viewer_id'
belongs_to :viewed, :polymorphic => true
validates_presence_of :viewed, :viewer
# after_save :increment_views_count
def self.last(limit=RECENTLY_VIEWED_LIMIT, usr=nil)
RecentlyViewed.where("#{RecentlyViewed.table_name}.viewer_id" => usr || User.current).order("#{RecentlyViewed.table_name}.updated_at DESC").limit(limit).collect{|v| v.viewed}.select(&:visible?).compact
end
private
def increment_views_count
self.increment!(:views_count)
end
end
@@ -0,0 +1,32 @@
# This file is a part of Redmine CRM (redmine_contacts) plugin,
# customer relationship management plugin for Redmine
#
# Copyright (C) 2010-2018 RedmineUP
# http://www.redmineup.com/
#
# redmine_contacts is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# redmine_contacts is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with redmine_contacts. If not, see <http://www.gnu.org/licenses/>.
class Task < ActiveRecord::Base
validates_presence_of :source_id, :issue_id, :source_type
validates_uniqueness_of :source_id, :scope => [:issue_id, :source_type]
after_save :send_mails
private
def send_mails
Mailer.deliver_contacts_issue_connected(Contact.find(contact_id), Issue.find(issue_id))
true
end
end
@@ -0,0 +1,10 @@
<%= raw @companies.map {|company| {
'id' => company.id,
'name' => company.name,
'avatar' => avatar_to(company, :size => 16),
'email' => company.primary_email,
'label' => company.name,
'value' => company.name
}
}.to_json
%>
@@ -0,0 +1,11 @@
<%= raw @contacts.map {|contact| {
'id' => contact.id,
'text' => contact.name_with_company,
'name' => contact.name,
'avatar' => avatar_to(contact, :size => 16),
'company' => contact.is_company ? "" : contact.company.to_s,
'email' => contact.primary_email,
'value' => contact.id
}
}.to_json
%>
@@ -0,0 +1,4 @@
<%= raw @tags.collect {|tag|
tag.name
}.to_json
%>
@@ -0,0 +1,9 @@
<%= raw @deals.map {|deal| {
'id' => deal.id,
'label' => "#{deal.full_name} (#{deal.info})",
'text' => "#{deal.name} (#{deal.info})",
'avatar' => avatar_to(deal, :size => 16),
'value' => deal.id
}
}.to_json
%>
@@ -0,0 +1,8 @@
<p class="address">
<label><%= l(:label_crm_address) %></label>
<%= f.text_field :street1, :no_label => true, :placeholder => l(:label_crm_street1), :style => "width:90%;" -%></p>
<p class="address street2"><%= f.text_field :street2, :no_label => true, :placeholder => l(:label_crm_street2) -%></p>
<p class="address city"><%= f.text_field :city, :no_label => true, :placeholder => l(:label_crm_city) -%> </p>
<p class="address region"><%= f.text_field :region, :no_label => true, :placeholder => l(:label_crm_region) -%></p>
<p class="address postcode"><%= f.text_field :postcode, :no_label => true, :placeholder => l(:label_crm_postcode), :size => 12 -%></p>
<p class="address country"><%= f.select :country_code, countries_options_for_select(f.object.country_code), :no_label => true, :placeholder => l(:label_crm_country), :include_blank => true -%></p>
@@ -0,0 +1,17 @@
<% actions ||= "" %>
<table class="note_data" id="contact_data_<%= contact_data.id %>">
<tr>
<td class="avatar"><%= link_to avatar_to(contact_data, :size => "32"), note_source_url(contact_data), :id => "avatar" %></td>
<td class="name">
<h4 class="contacts_header">
<%= link_to contact_data.name, note_source_url(contact_data) %>
</h4>
<%= contact_data.info %>
</td>
<% if !actions.blank? %>
<td>
<%= actions %>
</td>
<% end %>
</tr>
</table>
@@ -0,0 +1,29 @@
<% selected_tab = params[:tab] ? params[:tab].to_s : tabs.first[:name] %>
<div class="tabs">
<ul>
<% tabs.each do |tab| -%>
<li><%= link_to tab[:label], tabs_contact_path(@contact, :tab => tab[:name]),
:id => "tab-#{tab[:name]}",
:class => (tab[:name] != selected_tab ? 'tab-header' : 'selected tab-header'),
:data => { :name => tab[:name], :partial => tab[:partial], :project_id => @project},
:onclick => "showContactTab('#{tab[:name]}'); this.blur(); return false;" %></li>
<% end -%>
</ul>
<div class="tabs-buttons" style="display:none;">
<button class="tab-left" onclick="moveTabLeft(this);"></button>
<button class="tab-right" onclick="moveTabRight(this);"></button>
</div>
</div>
<% tabs.each do |tab| %>
<% selected = tab[:name] == selected_tab %>
<div class='tab-placeholder tab-content <%= 'active loaded' if selected %>' id='tab-placeholder-<%= tab[:name] %>' style='<%= "display: block" if selected %>'>
<%= render(:partial => tab[:partial]) if selected %>
</div>
<% end %>
<script type='text/javascript'>
$(document).ready(displayTabsButtons);
$(window).resize(displayTabsButtons);
$(document).ready(function(){ setupDeferredTabs('<%= load_tab_contact_path(@contact) %>') });
</script>
@@ -0,0 +1,9 @@
<script type="text/javascript">
var field_formats = ['contact', 'company'];
var contact_filter_urls = {
'company': "<%= auto_complete_contacts_path(:project_id => (ContactsSetting.cross_project_contacts? ? nil : @project), :is_company => true) %>",
'contact': "<%= auto_complete_contacts_path(:project_id => (ContactsSetting.cross_project_contacts? ? nil : @project)) %>"
};
field_formats.push('deal');
contact_filter_urls['deal'] = '<%= auto_complete_deals_path(:project_id => (ContactsSetting.cross_project_contacts? ? nil : @project)) %>';
</script>
@@ -0,0 +1,4 @@
<% if notes_attachments.any? %>
<h3><%= l(:label_attachment_plural) %></h3>
<%= render :partial => 'attachments/links', :locals => {:attachments => notes_attachments, :options => {}} %>
<% end %>
@@ -0,0 +1,4 @@
<h3><%= l(:label_crm_recently_viewed) %></h3>
<div id="recently_viewed">
<%= render :partial => 'common/contact_data', :collection => RecentlyViewed.includes(:viewed).last(5).map(&:viewed).select{|v| !v.blank? && v.visible?} %>
</div>
@@ -0,0 +1,10 @@
<% if responsible_user.assigned_to %>
<h3><%= l(:label_crm_assigned_to) %></h3>
<div id="responsible_user">
<ul>
<li>
<%= (avatar(responsible_user.assigned_to, :size => "16").to_s + link_to_user(responsible_user.assigned_to, :class => 'user').to_s).html_safe %>
</li>
</ul>
</div>
<% end %>
@@ -0,0 +1,15 @@
<%= call_hook(:view_contacts_sidebar_top) %>
<h3><%= l(:label_crm_module_plural) %></h3>
<% if User.current.allowed_to?(:view_contacts, @project, :global => true) %>
<%= link_to l(:label_contact_plural), { :controller => 'contacts', :action => 'index', :project_id => @project, :set_filter => 1} %>
|
<% end %>
<% if User.current.allowed_to?(:view_deals, @project, :global => true) %>
<%= link_to l(:label_deal_plural), { :controller => 'deals', :action => 'index', :project_id => @project, :set_filter => 1} %>
|
<% end %>
<%= link_to l(:label_crm_note_plural), { :controller => 'contacts', :action => 'contacts_notes', :project_id => @project} %>
<%= call_hook(:view_contacts_sidebar_bottom) %>
@@ -0,0 +1,74 @@
<div id="attributes" class="attributes">
<div class="contextual">
<%- if ContactsSetting.vcard? -%>
<%= link_to 'vCard', contact_path(@contact, :format => :vcf) %>
<%- end -%>
</div>
<h3><%= if !@contact.is_company then l(:label_contact) else l(:label_crm_company) end %></h3>
<table class="contact sidebar attributes vcard">
<%= call_hook(:view_contacts_sidebar_attributes_top) %>
<tr>
<th class = "name"><%= l(:field_contact_name) %>:</th><td class="name fn <%= "org" if @contact.is_company %>"><%= h @contact.name(:firstname_middlename_lastname) %></td>
</tr>
<% if !@contact.job_title.blank? %>
<tr> <th class = "job_title"><%= !@contact.is_company ? l(:field_contact_job_title) : l(:field_company_field) %>:</th><td class="job_title title"><%= h @contact.job_title %></td></tr>
<% end %>
<% if !@contact.is_company %>
<tr><th class = "company"><%=l(:field_contact_company)%>:</th><td class="company org"><%= h @contact.company %></td></tr>
<% end %>
<tr>
<th class = "address"><%= l(:field_contact_address) %>:</th>
<% unless @contact.address.blank? %>
<td class="address adr">
<%= @contact.post_address.gsub("\n", "<br/>").html_safe %>
<br>
<%= link_to l(:label_crm_show_on_map), "http://maps.google.com/maps?f=q&q=#{@contact.address}+(#{@contact.name})&ie=UTF8&om=1"%>
</td>
<% end %>
</tr>
<tr class = "tel">
<th class = "phone"><%= l(:field_contact_phone) %>:</th>
<td class = "phones">
<% @contact.phones.each do |phone| %>
<span class="value"><%= h phone %> <br></span>
<% end %>
</td>
</tr>
<tr class = "emails">
<th><%= l(:field_contact_email) %>:</th>
<td>
<% @contact.emails.each do |email| %>
<span class="email"><%= mail_to email %> <br></span>
<% end %>
</td>
</tr>
<tr>
<th class = "website"><%= l(:field_contact_website) %>:</th>
<td class="website url"><%= link_to @contact.website, @contact.website_address, :class => 'external' %></td>
</tr>
<% if !@contact.skype_name.blank? %>
<tr>
<th class = "skype"><%= l(:field_contact_skype) %>:</th>
<td class="skype"><%= skype_to @contact.skype_name %></td>
</tr>
<% end %>
<% if !@contact.birthday.blank? %>
<tr> <th class = "birthday"><%= l(:field_birthday) %>:</th><td class="birthday bday" title=<%= "#{format_date(@contact.birthday)}" %>><%= "#{@contact.birthday.day} #{t('date.month_names')[@contact.birthday.month]}"%></td> </tr>
<tr> <th class = "age"><%= l(:field_age) %>:</th><td class="ega"><%= @contact.age %></td> </tr>
<% end %>
<% @contact.custom_field_values.compact.each do |custom_value| %>
<% if !custom_value.value.blank? %>
<tr> <th class = "custom_field"><%= custom_value.custom_field.name%>:</th><td> <%= simple_format_without_paragraph(h(show_value(custom_value))) %></td> </tr>
<% end %>
<% end %>
<% if @contact.assigned_to %>
<tr><th class="author"><%=l(:label_crm_assigned_to)%>:</th><td class="author"><%= avatar(@contact.assigned_to, :size => "14") %><%= link_to_user(@contact.assigned_to) %></td></tr>
<% end %>
<%= call_hook(:view_contacts_sidebar_attributes_bottom) %>
</table>
</div>
@@ -0,0 +1,13 @@
<% @company_contacts = @contact.company_contacts.visible.uniq %>
<% if @contact.is_company %>
<div id="company_contacts">
<div class="contextual">
<%= link_to_if_authorized l(:label_crm_add_contact), {:controller => 'contacts', :action => 'new', :project_id => @project, :contact => {:company => @contact.name}} %>
</div>
<h3><%= l(:label_contact_plural) %></h3>
<%= render :partial => 'common/contact_data', :collection => @company_contacts %>
<div style="clear:both;"> </div>
</div>
<% end %>
@@ -0,0 +1,36 @@
<div class="contact card">
<table class="subject_header">
<tr>
<td class="avatar"><%= contact_tag(contact_card, :type => "avatar", :size => 64) %></td>
<td class="name" style="vertical-align: top;">
<h2><%= contact_tag(contact_card, :type => "plain") %> </h2>
<p>
<%= h contact_card.job_title %>
<% if !contact_card.is_company %>
<%= " #{l(:label_crm_at_company)} " unless (contact_card.job_title.blank? or contact_card.company.blank?) %>
<% if contact_card.contact_company %>
<%= link_to contact_card.contact_company.name, {:controller => 'contacts', :action => 'show', :project_id => contact_card.contact_company.project(@project), :id => contact_card.contact_company.id } %>
<% else %>
<%= h contact_card.company %>
<% end %>
<% end %>
</p>
</td>
</tr>
<tr>
<td colspan="2" style="padding-left: 10px;">
<% if contact_card.phones.any? %>
<p class="phone icon icon-phone"><%= contact_card.phones.first %></p>
<% end %>
<% if contact_card.emails.any? %>
<p class="email icon icon-email"><%= mail_to contact_card.emails.first %></p>
<% end %>
<%= tag_links(contact_card.tag_list) %>
</td>
</tr>
</table>
</div>
@@ -0,0 +1,4 @@
<p><%= form.check_box :is_filter %></p>
<% if (@custom_field.respond_to?(:format) && @custom_field.format.searchable_supported) || !@custom_field.respond_to?(:format) %>
<p><%= form.check_box :searchable %></p>
<% end %>
+105
View File
@@ -0,0 +1,105 @@
<%= back_url_hidden_field_tag %>
<%= error_messages_for 'contact' %>
<div class = "box tabular" id="contact_data">
<script type="text/javascript" charset="utf-8">
function togglePerson(element) {
if (element.checked) {
$('#person_data').hide();
$('#job_title label').get(0).innerHTML ='<%= l(:field_company_field) %>';
$('#first_name label').get(0).innerHTML='<%= l(:field_company_name) %>' + '<span class="required"> *</span>';
} else {
$('#person_data').show();
$('#job_title label').get(0).innerHTML ='<%= l(:field_contact_job_title) %>';
$('#first_name label').get(0).innerHTML='<%= l(:field_contact_first_name) %>' + '<span class="required"> *</span>';
}
}
</script>
<p class="avatar extended" id="watchers">
<%= avatar_to(@contact, :size => "64", :style => "vertical-align: middle;") %>
<%= link_to image_tag('delete.png'), {:controller => 'attachments', :action => 'destroy', :id => @contact.avatar},
:data => {:confirm => l(:text_are_you_sure)},
:method => :delete,
:class => 'delete',
:style => "vertical-align: middle;",
:title => l(:button_delete) unless @contact.avatar.blank? %>
</p>
<p class="extended">
<%= label_tag l(:field_contact_avatar) %>
<span id="attachments_fields"></span>
<span class="add_attachment">
<%= file_field_tag 'dummy_file',
:size => 30,
:id => nil,
:class => 'file_selector',
:multiple => true,
:onchange => 'uploadAvatar(this);',
:data => {
:max_file_size => Setting.attachment_max_size.to_i.kilobytes,
:max_file_size_message => l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)),
:max_concurrent_uploads => Redmine::Configuration['max_concurrent_ajax_uploads'].to_i,
:upload_path => uploads_path(:format => 'js'),
:description_placeholder => l(:label_optional_description)
} %>
</span>
</p>
<p><%= f.check_box(:is_company, :label => l(:field_contact_is_company), :onclick => "togglePerson(this)" ) %></p>
<p id="first_name"><%= f.text_field :first_name, :label => !@contact.is_company ? l(:field_contact_first_name) : l(:field_company_name), :required => true, :style => "width:90%;" %></p>
<div id="person_data" style="<%= 'display: none;' if @contact.is_company %>">
<p><%= f.text_field :middle_name, :label=>l(:field_contact_middle_name) %></p>
<p><%= f.text_field :last_name, :label=>l(:field_contact_last_name), :id => 'contact_last_name' %></p>
<p><%= f.text_field :company, :label=>l(:field_contact_company) -%></p>
<%= javascript_tag "observeAutocompleteField('contact_company', '#{escape_javascript auto_complete_companies_path}')" %>
<p class="extended"><%= f.text_field :birthday, :size => 12 %><%= calendar_for('contact_birthday') %> </p>
</div>
<p id="job_title"><%= f.text_field :job_title, :label => !@contact.is_company ? l(:field_contact_job_title) : l(:field_company_field) %></p>
<% @contact.build_address if @contact.address.blank? %>
<%= f.fields_for(:address) do |a| %>
<span class="extended">
<%= render :partial => 'common/address_form', :locals => {:f => a} %>
</span>
<% end %>
<div id="phones_fields">
<p>
<%= f.text_field :phone, :label=>l(:field_contact_phone), :style => "width:90%;" -%>
<br>
<em class="info"><%= l(:text_comma_separated) %></em>
</p>
</div>
<p>
<%= f.text_field 'email', :label=>l(:field_contact_email), :style => "width:90%;" -%>
<br>
<em class="info"><%= l(:text_comma_separated) %></em>
</p>
<p class="extended"><%= f.text_field 'website', :label=>l(:field_contact_website) -%></p>
<p class="extended"><%= f.text_field 'skype_name', :label=>l(:field_contact_skype) -%></p>
<% @contact.custom_field_values.each do |value| %>
<p class="<%= "extended" unless value.custom_field.is_required? %>">
<%= custom_field_tag_with_label :contact, value %>
</p>
<% end -%>
<p class="extended notes"><%= f.text_area :background , :cols => 80, :rows => 8, :class => 'wiki-edit', :label=>l(:field_contact_background) %></p>
<%= wikitoolbar_for 'contact_background' %>
<p class="extended">
<%= label_tag l(:label_crm_tags_plural) %>
<%= render :partial => "contacts_tags/tags_form" %>
</p>
<% if @project %>
<p class="extended"><%= f.select :assigned_to_id, (@project.assignable_users.collect {|m| [m.name, m.id]}), :include_blank => true, :label => l(:label_crm_assigned_to) %></p>
<% end %>
<p class="extended"><%= f.select :visibility, collection_for_visibility_select, :include_blank => false, :label => l(:label_crm_contacts_visibility) %></p>
<p id="show_details_link" style="display: none;"><%= link_to l(:label_crm_show_details), "javascript:void(0);", :onclick => "$('#contact_data .extended').show();" %></p>
</div>
@@ -0,0 +1,24 @@
<div id="tags_data">
<%= tag_links(@contact.tag_list) %>
<% if authorize_for('contacts', 'update') %>
<span class="contextual">
<%= link_to l(:label_crm_edit_tags), {}, :onclick => "$('#edit_tags_form').show(); $('#tags_data').hide(); return false;", :id => 'edit_tags_link' %>
</span>
<% end %>
</div>
<div id="edit_tags_form" style="display:none;">
<%= form_tag( {:controller => 'contacts',
:action => 'update',
:project_id => @project,
:id => @contact },
:method => :put,
:multipart => true ) do %>
<%= render :partial => "contacts_tags/tags_form" %>
<%= submit_tag l(:button_save), :class => "button-small" %>
<%= link_to l(:button_cancel), {}, :onclick => "$('#edit_tags_form').hide(); $('#tags_data').show(); return false;" %>
<% end %>
</div>
@@ -0,0 +1,44 @@
<%= form_tag({}, :data => {:cm_url => context_menu_contacts_path}) do %>
<%= hidden_field_tag 'back_url', url_for(params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params) %>
<%= hidden_field_tag 'project_id', @project.id if @project %>
<div class="autoscroll">
<table class="list contacts">
<thead>
<tr>
<th class="checkbox hide-when-print">
<%= link_to image_tag('toggle_check.png'), {}, :onclick => 'toggleCRMIssuesSelection(this); return false;',
:title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %>
</th>
<% @query.columns.each do |column| %>
<%= Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch? ? column_header(@query, column) : column_header(column) %>
<% end %>
</tr>
</thead>
<% previous_group = false %>
<tbody>
<% @contacts.each do |contact| -%>
<% if @query.grouped? && (group = @query.group_by_column.value(contact)) != previous_group %>
<% reset_cycle %>
<tr class="group open">
<td colspan="<%= @query.columns.size + 2 %>">
<span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
<%= group.blank? ? l(:label_none) : column_content(@query.group_by_column, contact) %> <span class="count">(<%= @contact_count_by_group[group] %>)</span>
<%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}", "toggleAllRowGroups(this)", :class => 'toggle-all') %>
</td>
</tr>
<% previous_group = group %>
<% end %>
<tr id="contact-<%= h(contact.id) %>" class="contact <%= 'company' if contact.is_company %> hascontextmenu <%= cycle('odd', 'even') %>">
<td class="checkbox hide-when-print"><%= check_box_tag("selected_contacts[]", contact.id, false, :id => nil) %></td>
<% @query.columns.each do |column| %><%= content_tag 'td', column_content(column, contact), :class => column.css_classes %><% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
@@ -0,0 +1,51 @@
<%= form_tag({}, :data => {:cm_url => context_menu_contacts_path}) do %>
<%= hidden_field_tag 'back_url', url_for(params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params) %>
<%= hidden_field_tag 'project_id', @project.id if @project %>
<div class="splitcontentleft">
<% i = 0 %>
<% split_on = (@contacts.size / 2.0).ceil - 1 %>
<% @contacts.each do |contact| %>
<% @contact = contact %>
<div class="contact card">
<table class="subject_header">
<tr>
<td class="avatar"><%= contact_tag(contact, :type => "avatar", :size => 64) %></td>
<td class="name" style="vertical-align: top;">
<h2><%= contact_tag(contact, :type => "plain") %> </h2>
<p>
<%= h contact.job_title %>
<% if !contact.is_company %>
<%= " #{l(:label_crm_at_company)} " unless (contact.job_title.blank? or contact.company.blank?) %>
<% if contact.contact_company %>
<%= link_to contact.contact_company.name, {:controller => 'contacts', :action => 'show', :project_id => contact.contact_company.project(@project), :id => contact.contact_company.id } %>
<% else %>
<%= h contact.company %>
<% end %>
<% end %>
</p>
<% if contact.phones.any? %>
<p class="phone icon icon-phone"><%= contact.phones.first %></p>
<% end %>
<% if contact.emails.any? %>
<p class="email icon icon-email"><%= mail_to contact.emails.first %></p>
<% end %>
<%= tag_links(contact.tag_list) %>
</td>
</tr>
</table>
</div>
<% if i == split_on -%>
</div><div class="splitcontentright">
<% end -%>
<% i += 1 -%>
<% end -%>
</div>
<div style="clear:both;"> </div>
<% end %>
@@ -0,0 +1,54 @@
<%= form_tag({}, :data => {:cm_url => context_menu_contacts_path}) do %>
<%= hidden_field_tag 'back_url', url_for(params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params) %>
<%= hidden_field_tag 'project_id', @project.id if @project %>
<div class="autoscroll">
<table class="contacts index">
<tbody>
<% previous_group = false %>
<% @contacts.each do |contact| %>
<% if @query.grouped? && (group = @query.group_by_column.value(contact)) != previous_group %>
<% reset_cycle %>
<tr class="group open">
<td colspan="<%= @query.columns.size + 2 %>">
<span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
<%= group.blank? ? 'None' : column_content(@query.group_by_column, contact) %> <span class="count">(<%= @contact_count_by_group[group] %>)</span>
<%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}", "toggleAllRowGroups(this)", :class => 'toggle-all') %>
</td>
</tr>
<% previous_group = group %>
<% end %>
<tr class="hascontextmenu <%= cycle('odd', 'even') %> ">
<td class="checkbox">
<%= check_box_tag "selected_contacts[]", contact.id, false, :onclick => "toggleContact(event, this);" %>
</td>
<td class="avatar">
<%= link_to avatar_to(contact, :size => "32"), contact_path(contact, :project_id => @project), :id => "avatar" %>
</td>
<td class="name">
<h1><%= link_to contact.name, contact_path(contact, :project_id => @project) %></h1>
<p>
<%= link_to contact.website, contact.website_address, :class => 'external', :only_path => true unless !contact.is_company %>
<%= mail_to contact.emails.first unless contact.is_company%>
<div><%= contact.phones.first %></div>
</p>
</td>
<td class="info">
<div class="title_and_company" >
<%= contact.job_title %>
<% if !contact.is_company %>
<%= " #{l(:label_crm_at_company)} " unless (contact.job_title.blank? or contact.company.blank?) %>
<%= contact.company %>
<% end %>
</div>
<div class="tags">
<%= tag_links(contact.tag_list) %>
<%# tag_links(RedmineCrm::TagList.from(contact.cached_tag_list)) %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
@@ -0,0 +1,37 @@
<script type="text/javascript">
jQuery(function($) {
// when the #search field changes
var duplicates = function() {
var form = $("#contact_form"); // grab the form wrapping the search bar.
var url = '<%= escape_javascript(url_for({:controller => "contacts_duplicates", :action => "duplicates", :project_id => @project, :contact_id => @contact})) %>';
var formData = form.serialize();
$.get(url, formData, function(data) { // perform an AJAX get, the trailing function is what happens on successful get.
$("#duplicates").html(data); // replace the "results" div with the result of action taken
});
}
$("#contact_first_name").observe_field(2, duplicates);
$("#contact_middle_name").observe_field(2, duplicates);
$("#contact_last_name").observe_field(2, duplicates);
});
</script>
<%# observe_field("contact_first_name",
:frequency => 1,
:update => 'duplicates',
:url => {:controller => 'contacts_duplicates', :action => 'duplicates', :project_id => @project, :contact_id => @contact},
:with => "$('contact_form').serialize()") %>
<%# observe_field("contact_middle_name",
:frequency => 1,
:update => 'duplicates',
:url => {:controller => 'contacts_duplicates', :action => 'duplicates', :project_id => @project, :contact_id => @contact},
:with => "$('contact_form').serialize()") %>
<%# observe_field("contact_last_name",
:frequency => 1,
:update => 'duplicates',
:url => {:controller => 'contacts_duplicates', :action => 'duplicates', :project_id => @project, :contact_id => @contact},
:with => "$('contact_form').serialize()") %>
@@ -0,0 +1,15 @@
<h3 class="title"><%=l(:label_crm_contact_new)%></h3>
<%= labelled_form_for @contact, :url => project_contacts_path(@project), :remote => true do |f| %>
<%= hidden_field_tag :contact_field_name, params[:contact_field_name] %>
<%= hidden_field_tag :contacts_is_company, params[:contacts_is_company] %>
<%= render :partial => 'contacts/form', :locals => { :f => f } %>
<p class="buttons">
<%= submit_tag l(:button_create), :name => nil %>
<%= submit_tag l(:button_cancel), :name => nil, :onclick => "hideModal(this);", :type => 'button' %>
</p>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<% end %>
@@ -0,0 +1,29 @@
<%
extend Redmine::Pagination
source_id_cond = @contact.is_company ? Contact.visible.where(:company => @contact.first_name).map(&:id) << @contact.id : @contact.id
@note = Note.new(:created_on => Time.now)
scope = Note.where({:source_id => source_id_cond, :source_type => 'Contact'}).includes(:attachments).order("#{Note.table_name}.created_on DESC")
@notes_pages = Redmine::Pagination::Paginator.new(scope.count, 20, params['page'])
@notes = scope.limit(20).offset(@notes_pages.offset)
%>
<% if authorize_for(:notes, :create) %>
<div class="add-note hide-when-print">
<%= render :partial => 'notes/add', :locals => {:note_source => @contact} %>
</div>
<% end %>
<% if @contact.is_public? || authorize_for(:notes, :show) %>
<div id="comments">
<div id="notes">
<%= render :partial => 'notes/note_item', :collection => @notes, :locals => {:show_info => @contact.is_company, :note_source => @contact} %>
<span class="pagination"> <%= pagination_links_full @notes_pages %> </span>
</div>
</div>
<% other_formats_links do |f| %>
<% filtered_params = params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params %>
<%= f.link_to 'Atom', :url => filtered_params.merge(:key => User.current.rss_key) %>
<% end %>
<% end %>
@@ -0,0 +1,10 @@
<div id="tags_data">
<span class="tags">
<%= render :partial => 'contacts/tags_item', :collection => tag_list, :locals => {:is_note => false} %>
</span>
<% if editable && authorize_for('contacts', 'update') %>
<span class="contextual">
<%= link_to l(:label_crm_edit_tags), {}, :onclick => "$('#edit_tags_form').show(); $('#tags_data').hide(); return false;", :id => 'edit_tags_link' %>
</span>
<% end %>
</div>
@@ -0,0 +1,7 @@
<div id="tags">
<span id="single_tags">
<h3><%= l(:label_crm_tags_plural) %></h3>
<span class="tag_list"><%= safe_join(tags_cloud.map{|tag| tag_link(tag.name, :count => tag.count)}, ContactsSetting.monochrome_tags? ? ', ' : ' ').html_safe %></span>
</span>
</div>
@@ -0,0 +1,19 @@
<%
html_options = {:id => "tag_#{tags_item.id}",
:style => "background-color: #{tags_item.color_name}"}
taggable_type ||= 'contacts'
tag_url = {:controller => taggable_type,
:action => 'index',
:set_filter => 1,
:fields => [:tags],
:values => {:tags => [tags_item.name]},
:operators => {:tags => '='}}
%>
<span class="tag" >
<%- if !is_note -%>
<%= link_to tags_item.name + "#{"(" + tags_item.count.to_s + ")" if tags_item.count > 0}", {:project_id => @project}.merge!(tag_url), html_options %>
<%- else -%>
<%= link_to tags_item.name, {:controller => "contacts", :action => "contacts_notes", :project_id => @project, :tag => tags_item.name}, html_options %>
<%- end -%>
</span>
@@ -0,0 +1,130 @@
<h2><%= l(:label_crm_bulk_edit_selected_contacts) %></h2>
<div class="box" id="duplicates">
<ul>
<% @contacts.each do |contact| %>
<li>
<%= avatar_to contact, :size => "16" %>
<%= link_to_source contact %>,
<%= h contact.job_title %>
<%= " #{l(:label_crm_at_company)} " unless (contact.job_title.blank? or contact.company.blank?) %>
<% if contact.contact_company %>
<%= link_to contact.contact_company.name, {:controller => 'contacts', :action => 'show', :id => contact.contact_company.id } %>
<% else %>
<%= h contact.company %>
<% end %>
<%= "(#{l(:field_contact_tag_names)}: #{contact.tag_list})" if contact.tags.any? %>
</li>
<% end %>
</ul>
</div>
<%= form_tag(:action => 'bulk_update', :project_id => @project) do %>
<%= @contacts.collect {|i| hidden_field_tag('ids[]', i.id)}.join.html_safe %>
<div class="box tabular">
<fieldset class="attributes">
<legend><%= l(:label_change_properties) %></legend>
<div class="splitcontentleft">
<p>
<label><%= l(:field_company) %></label>
<%= text_field_tag('contact[company]', '') %>
<%= javascript_tag "observeAutocompleteField('contact_company', '#{escape_javascript auto_complete_companies_path(:project_id => @project)}')" %>
</p>
<% @custom_fields.each do |custom_field| %>
<p><label><%= h(custom_field.name) %></label> <%= custom_field_tag_for_bulk_edit('contact', custom_field, @projects) %></p>
<% end %>
<p>
<label><%= l(:label_crm_assigned_to) %></label>
<%= select_tag('contact[assigned_to_id]', content_tag('option', l(:label_no_change_option), :value => '') +
content_tag('option', l(:label_nobody), :value => 'none') +
options_from_collection_for_select(@assignables, :id, :name)) %>
</p>
<p>
<label><%= l(:label_crm_contacts_visibility) %></label>
<%= select_tag 'contact[visibility]', options_for_select(collection_for_visibility_select) %>
</p>
</div>
<div class="splitcontentright">
<p>
<label><%= l(:field_contact_job_title) %>/<%= l(:field_company_field) %></label>
<%= text_field_tag('contact[job_title]', '') %>
</p>
<p class="address city">
<label><%= l(:label_crm_city) %></label>
<%= text_field_tag 'contact[address_attributes][city]' -%>
</p>
<p class="address region">
<label><%= l(:label_crm_region) %></label>
<%= text_field_tag 'contact[address_attributes][region]' -%>
</p>
<p class="address country">
<label><%= l(:label_crm_country) %></label>
<%= select_tag 'contact[address_attributes][country_code]', options_for_select(l(:label_crm_countries).map{|k, v| [v, k]}), :include_blank => true -%>
</p>
</div>
</fieldset>
<fieldset class="attributes">
<legend><%= l(:label_crm_tags_plural) %></legend>
<div class="splitcontentleft">
<p id="add_tags" class="contacts-tags-edit">
<label><%= l(:field_add_tags) %></label>
<%= text_field_tag 'add_tag_list', '', :size => 10, :class => "hol" %><%= tagsedit_with_source_for("#add_tag_list", auto_complete_contact_tags_path(:project_id => @project)) %>
</p>
</div>
<div class="splitcontentright">
<p id="delete_tags" class="contacts-tags-edit">
<label><%= l(:field_delete_tags) %></label>
<%= text_field_tag 'delete_tag_list', '', :label => :field_contact_tag_names, :size => 10, :class => "hol" %><%= tagsedit_with_source_for("#delete_tag_list", auto_complete_contact_tags_path(:project_id => @project)) %>
</p>
</div>
</fieldset>
<% if @add_projects.any? %>
<fieldset class="attributes">
<legend><%= l(:label_project_plural) %></legend>
<div class="splitcontentleft">
<p>
<label><%= l(:label_crm_add_into) %></label>
<%= select_tag 'add_projects_list[]', content_tag('option', l(:label_no_change_option), :value => '', :selected => 'selected') + project_tree_options_for_select(@add_projects), :multiple => false %>
</p>
</div>
<div class="splitcontentright">
<p>
<label><%= l(:label_crm_delete_from) %></label>
<%= select_tag 'delete_projects_list[]', content_tag('option', l(:label_no_change_option), :value => '', :selected => 'selected') + project_tree_options_for_select(@add_projects), :multiple => false %>
</p>
</div>
</fieldset>
<% end %>
<fieldset><legend><%= l(:field_notes) %></legend>
<%= text_area_tag 'note[content]', '', :cols => 60, :rows => 10, :class => 'wiki-edit' %>
<%= wikitoolbar_for 'note_content' %>
</fieldset>
</div>
<p><%= submit_tag l(:button_submit) %></p>
<% end %>
@@ -0,0 +1,64 @@
<% filtered_params = params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params %>
<div class="filters">
<% if !@tag %>
<%= form_tag(filtered_params, :id => "query_form", :method => :get) do %>
<%= hidden_field_tag('project_id', @project.to_param) if @project %>
<h2>
<span class="scope_title">
<%= l(:label_crm_contact_all_note_plural) %>
</span>
<span class="live_search">
<%= text_field_tag(:search_note, params[:search_note], :autocomplete => "off", :class => "live_search_field", :placeholder => l(:label_crm_contact_search) ) %>
<script type="text/javascript">
jQuery(function($) {
// when the #search field changes
$("#search_note").observe_field(2, function() {
var form = $("#query_form"); // grab the form wrapping the search bar.
var url = form.attr("action");
var formData = form.serialize();
$.get(url, formData, function(data) { // perform an AJAX get, the trailing function is what happens on successful get.
$("#contacts_notes").html(data); // replace the "results" div with the result of action taken
});
});
});
</script>
</span>
</h2>
<% end %>
<% else %>
<h2 class="scope_title"><%= "#{l(:label_crm_contact_tag)}(#{@notes_pages.item_count}): #{tag_links(@tag)}".html_safe %> </h2>
<% end %>
</div>
<div id="contacts_notes">
<%= render :partial => 'notes/notes_list' %>
</div>
<% other_formats_links do |f| %>
<%= f.link_to 'Atom', :url => filtered_params.merge(:key => User.current.rss_key) %>
<%= f.link_to 'CSV', :url => filtered_params %>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'common/sidebar' %>
<h3><%= l(:label_crm_note_plural) %></h3>
<div id="note_types">
<% collection_for_note_types_select.each do |note_type| %>
<%= radio_button_tag "note_type", note_type[1], filtered_params[:type_id].to_s == note_type[1].to_s, {:onchange => "document.location='#{url_for(filtered_params.merge(:type_id => note_type[1]))}';", :id => "note_type_#{note_type[1]}" }%>
<%= label_tag "note_type_#{note_type[1]}", note_type[0] %>
<br>
<% end %>
</div>
<h3><%= l(:label_crm_tags_plural) %></h3>
<div id="tags">
<span class="tag_list"><%= @tags.map{|tag| content_tag(:span, link_to(tag.name, {:controller => "contacts", :action => "contacts_notes", :project_id => @project, :tag => tag.name}), {}.merge(ContactsSetting.monochrome_tags? ? {:class => "tag-label"} : {:class => "tag-label-color", :style => "background-color: #{tag_color(tag.name)}"}))}.join(' ').html_safe %></span>
</div>
<%= render :partial => 'common/recently_viewed' %>
<% end %>
<% content_for(:header_tags) do %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :contacts_sidebar, :plugin => 'redmine_contacts' %>
<% end %>
@@ -0,0 +1,35 @@
<ul>
<%= call_hook(:view_contacts_context_menu_start, {:contacts => @contacts, :can => @can, :back => @back }) %>
<% unless @contact.nil? %>
<li><%= context_menu_link l(:button_edit), {:controller => 'contacts', :action => 'edit', :id => @contact, :project_id => @project}, :class => 'icon-edit', :disabled => !@can[:edit] %></li>
<% if User.current.logged? %>
<li><%= watcher_link(@contact, User.current) %></li>
<% end %>
<% if !@project.nil? %>
<li><%= context_menu_link l(:label_crm_deal_new), {:controller => 'deals', :action => 'new', :project_id => @project, :contact_id => @contact},
:class => 'icon-add-deal', :disabled => !@can[:create_deal] %></li>
<% if @contact.is_company? %>
<li><%= context_menu_link l(:label_crm_add_contact), {:controller => 'contacts', :action => 'new', :project_id => @project, :contact => {:company => @contact.name}},
:class => 'icon-company-contact', :disabled => !@can[:create] %></li>
<% end %>
<% end %>
<% else %>
<li><%= context_menu_link l(:button_edit), {:controller => 'contacts', :action => 'bulk_edit', :ids => @contacts.collect(&:id)},
:class => 'icon-edit', :disabled => !@can[:edit] %></li>
<% end %>
<li><%= context_menu_link l(:label_crm_send_mail), {:controller => 'contacts', :action => 'edit_mails', :ids => @contacts.collect(&:id), :project_id => @project}, :class => 'icon-email', :disabled => !@can[:send_mails] %></li>
<%= call_hook(:view_contacts_context_menu_before_delete, {:contacts => @contacts, :can => @can, :back => @back }) %>
<li><%= context_menu_link l(:button_delete), {:controller => 'contacts', :action => 'bulk_destroy', :ids => @contacts.collect(&:id), :project_id => @project},
:method => :delete, :data => {:confirm => l(:text_are_you_sure)}, :class => 'icon-del', :disabled => !@can[:delete] %></li>
<% if !@contact && Redmine::VERSION.to_s >= '3.3' %>
<li>
<%= context_menu_link l(:button_filter), _project_contacts_path(@project, :set_filter => 1, :ids => @contacts.map(&:id).join(',')),
:class => 'icon-list' %>
</li>
<% end %>
<%= call_hook(:view_contacts_context_menu_end, {:contacts => @contacts, :can => @can, :back => @back }) %>
</ul>
@@ -0,0 +1,13 @@
hideModal();
<% field_id = params[:contact_field_name].to_s.gsub("[", "_").gsub("]", "") -%>
$('select#<%= field_id %>')
.append($("<option></option>")
.attr("value",'<%= @contact.id %>')
.attr("selected",'selected')
.text('<%= @contact.name %>'));
$('input#<%= field_id %>').val('<%= @contact.id %>');
$('#<%= field_id %>_selected_contact').text('<%= @contact.name %>');
$('#<%= field_id %>_selected_contact').show();
$('#<%= field_id %>_selected_contact').scrollTop( 0 );
$('input#<%= field_id %>').hide();
$('#<%= field_id %>_edit_link').show();
+28
View File
@@ -0,0 +1,28 @@
<div class="contextual">
<%= link_to_if_authorized l(:label_crm_merge_duplicate_plural), {:controller => 'contacts_duplicates', :action => 'index', :project_id => @project, :contact_id => @contact}, :class => 'icon icon-merge' unless @contact.new_record? %>
</div>
<h2><%= l(:label_crm_contact_edit_information) %></h2>
<%= labelled_form_for :contact, @contact,
:url => {:action => 'update', :project_id => @project, :id => @contact},
:html => { :multipart => true, :method => :put, :id => "contact_form" } do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= render :partial => 'name_observer' %>
<%= submit_tag l(:button_save) -%>
<% end -%>
<% content_for :sidebar do %>
<%= render :partial => 'common/sidebar' %>
<%= render :partial => 'contacts_duplicates/duplicates' %>
<div id="contact_projects">
<%= render :partial => 'contacts_projects/related' %>
</div>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag 'attachments' %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :contacts_sidebar, :plugin => 'redmine_contacts' %>
<%= robot_exclusion_tag %>
<% end %>
@@ -0,0 +1,64 @@
<h2><%= l(:label_crm_bulk_send_mail_selected_contacts) %></h2>
<div class="box" id="duplicates">
<ul>
<% @contacts.each do |contact| %>
<li>
<%= avatar_to contact, :size => "16" %>
<%= link_to_source contact %>
<%= "(#{contact.job_title}) " unless contact.job_title.blank? %>
- <%= contact.emails.first %>
</li>
<% end %>
</ul>
</div>
<%= form_for(:email_message, :url => {:action => 'send_mails', :project_id => @project}, :html => {:multipart => true, :id => 'message-form'}) do %>
<%= @contacts.collect {|i| hidden_field_tag('ids[]', i.id)}.join.html_safe %>
<div class="box tabular">
<p>
<label><%= l(:field_mail_from) %></label>
<%= text_field_tag('from', "#{User.current.name} <#{User.current.mail}>", :style => "width: 98%;") %>
<%= link_to "#{l(:label_crm_contacts_cc)}/#{l(:label_crm_contacts_bcc)}", '#' , :onclick => "$('#mail_cc').show();$(this).hide();" %>
</p>
<span id="mail_cc" style="display:none;">
<p>
<label><%= l(:label_crm_contacts_cc) %></label>
<%= text_field_tag('cc', '', :style => "width: 98%;") %>
</p>
<p>
<label><%= l(:label_crm_contacts_bcc) %></label>
<%= text_field_tag('bcc', '', :style => "width: 98%;") %>
</p>
</span>
<p>
<label><%= l(:field_subject) %></label>
<%= text_field_tag('subject', '', :id => "subject", :style => "width: 98%;") %>
</p>
<p>
<label><%= l(:field_message) %></label>
<%= text_area_tag 'message-content', '', :cols => 60, :rows => 10, :class => 'wiki-edit' %>
<em class="info"><%= l(:text_email_macros, :macro => "%%NAME%%, %%LAST_NAME%%, %%MIDDLE_NAME%%, %%FULL_NAME%%, %%COMPANY%%, %%DATE%%, %%[Custom field]%%") %></em>
</p>
<%= wikitoolbar_for 'message-content' %>
<p id="attachments_form"><%= label_tag('attachments[1][file]', l(:label_attachment_plural))%><%= render :partial => 'attachments/form' %></p>
</div>
<p>
<%= submit_tag l(:button_submit) %>
<%= preview_link({ :controller => 'contacts', :action => 'preview_email' }, 'message-form') %>
</p>
<% end %>
<div id="preview" class="wiki"></div>
<% content_for :header_tags do %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<% end %>
@@ -0,0 +1,53 @@
api.array :contacts, api_meta(:total_count => @contacts_count, :offset => @offset, :limit => @limit) do
@contacts.each do |contact|
api.contact do
api.id contact.id
api.avatar(:attachment_id => contact.avatar.id) if contact.avatar
api.is_company contact.is_company
api.first_name contact.first_name
api.last_name contact.last_name
api.middle_name contact.middle_name
api.company contact.company
api.website contact.website
api.skype_name contact.skype_name
api.birthday contact.birthday
api.job_title contact.job_title
api.background contact.background
api.author(:id => contact.author_id, :name => contact.author.name) unless contact.author.nil?
api.assigned_to(:id => contact.assigned_to_id, :name => contact.assigned_to.name) unless contact.assigned_to.nil?
api.address do
api.full_address contact.address
api.street contact.street1
api.city contact.city
api.region contact.region
api.country contact.country
api.country_code contact.address.country_code unless contact.address.blank?
api.postcode contact.postcode
end
api.array :phones do
contact.phones.each do |phone|
api.phone do
api.number phone
end
end
end if contact.phones.any?
api.array :emails do
contact.emails.each do |email|
api.email do
api.address email
end
end
end if contact.emails.any?
api.tag_list contact.tag_list
render_api_custom_values contact.custom_field_values, api
api.created_on contact.created_on
api.updated_on contact.updated_on
end
end
end
+203
View File
@@ -0,0 +1,203 @@
<% filtered_params = params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params %>
<div class="contextual">
<% if !@query.new_record? && @query.editable_by?(User.current) %>
<%= link_to l(:button_contacts_edit_query), edit_crm_query_path(@query, :object_type => "contact"), :class => 'icon icon-edit' %>
<%= link_to l(:button_contacts_delete_query), crm_query_path(@query, :object_type => "contact"), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %>
<% end %>
<%= link_to_if_authorized l(:label_crm_contact_new), {:controller => 'contacts', :action => 'new', :project_id => @project}, :class => 'icon icon-add' %>
<%= link_to_if_authorized l(:label_crm_import), {:controller => 'contact_imports', :action => 'new', :project_id => @project}, :class => 'icon icon-import', :id => 'import_from_csv' %>
<%= call_hook(:view_contacts_action_menu) %>
</div>
<% html_title(@query.new_record? ? l(:label_contact_plural) : @query.name) %>
<%= form_tag({ :controller => 'contacts', :action => 'index', :project_id => @project }, :method => :get, :id => 'query_form') do %>
<script type="text/javascript">
jQuery(function($) {
// when the #search field changes
$("#search").observe_field(2, function() {
var form = $("#query_form"); // grab the form wrapping the search bar.
var url = form.attr("action");
form.find('[name="c[]"] option').each(function(i, elem){
$(elem).attr('selected', true)
})
var formData = form.serialize();
form.find('[name="c[]"] option').each(function(i, elem){
$(elem).attr('selected', false)
})
$.get(url, formData, function(data) { // perform an AJAX get, the trailing function is what happens on successful get.
$("#contact_list").html(data); // replace the "results" div with the result of action taken
});
});
});
</script>
<h2 class="contacts_header">
<span id='scope_header' class="scope_title">
<%= @query.new_record? ? l(:label_contact_plural) : h(@query.name) %>
</span>
<span class="live_search">
<%= text_field_tag(:search, params[:search], :autocomplete => "off", :class => "live_search_field", :placeholder => l(:label_crm_contact_search) ) %>
</span>
<span class="tags">
<%= tag_links(@filter_tags) %>
</span>
</h2>
<%= hidden_field_tag 'set_filter', '1' %>
<%= hidden_field_tag 'object_type', 'contact' %>
<div id="query_form_content" class="hide-when-print">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
<%= render :partial => 'queries/filters', :locals => {:query => @query} %>
</div>
</fieldset>
<fieldset class="collapsible collapsed">
<legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
<div style="display: none;">
<table>
<tr style="<%= 'display: none;' if contacts_list_style != 'list' %>">
<td><%= l(:field_column_names) %></td>
<td><%= render_query_columns_selection(@query) %></td>
</tr>
<tr>
<% if contacts_list_style != 'list_cards' %>
<td><label for='group_by'><%= l(:field_group_by) %></label></td>
<td><%= select_tag('group_by', options_for_select([[]] + @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, @query.group_by)) %></td>
<% end -%>
<% if contacts_list_style != 'list' %>
<td><label for='sort'><%= l(:label_sort) %></label></td>
<td><%= select_tag('sort',
options_for_select(
[[]] +
[[l(:field_contact_job_title), "job_title"],
[l(:field_contact_company), "company"],
[l(:field_created_on), "created_on:desc"],
[l(:field_updated_on), "updated_on:desc"]],
params[:sort])
) %></td>
<% end %>
</tr>
<tr>
<td><label for='contacts_list_style'><%= l(:label_crm_list_partial_style) %></label></td>
<td><%= select_tag('contacts_list_style', options_for_select(contact_list_styles_for_select, contacts_list_style)) %></td>
</tr>
</table>
</div>
</fieldset>
</div>
<p class="buttons hide-when-print">
<%= link_to_function l(:button_apply), 'submit_query_form("query_form")', :class => 'icon icon-checked' %>
<%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload' %>
<% if @query.new_record? && User.current.allowed_to?(:save_contacts_queries, @project, :global => true) %>
<%= link_to_function l(:button_save),
"$('#query_form').attr('action', '#{ @project ? new_project_crm_query_path(@project) : new_crm_query_path }'); submit_query_form('query_form')",
:class => 'icon icon-save' %>
<% end %>
</p>
<% end %>
<%= error_messages_for 'query' %>
<% if @query.valid? %>
<div id="contact_list">
<% if @contacts.empty? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<%= render :partial => contacts_list_style %>
<span class="pagination"><%= pagination_links_full @contacts_pages, @contacts_count %></span>
<% end %>
</div>
<% if User.current.allowed_to?(:export_contacts, @project, :global => true) %>
<% other_formats_links do |f| %>
<%= f.link_to 'Atom', :url => filtered_params.merge(:key => User.current.rss_key) %>
<% if contacts_list_style == 'list' %>
<%= f.link_to 'CSV', :url => filtered_params, :onclick => "showModal('csv-export-options', '350px'); return false;" %>
<% else %>
<%= f.link_to 'CSV', :url => filtered_params %>
<% end %>
<%- if ContactsSetting.vcard? -%>
<%= f.link_to 'VCF', :url => filtered_params %>
<%- end -%>
<%- if ContactsSetting.spreadsheet? -%>
<%= f.link_to 'XLS', :url => filtered_params %>
<%- end -%>
<% end %>
<div id="csv-export-options" style="display:none;">
<h3 class="title">
<%= l(:label_export_options, :export_format => 'CSV') %>
</h3>
<%= form_tag({:controller => 'contacts',
:action => 'index',
:project_id => @project,
:format => 'csv'},
:method => :get,
:id => 'csv-export-form') do %>
<% columns_name = 'c[]' %>
<% if Redmine::VERSION::STRING > '3.2.1' %>
<%= query_as_hidden_field_tags(@query) %>
<% if Redmine::VERSION::STRING < '3.4' %>
<%= hidden_field_tag 'sort', @sort_criteria.to_param, :id => nil %>
<% columns_name = 'csv[columns]' %>
<% end %>
<% elsif Redmine::VERSION::STRING < '3.2' %>
<% columns_name = 'columns' %>
<% end %>
<p>
<label>
<%= radio_button_tag columns_name, '', true %>
<%= l(:description_selected_columns) %>
</label>
<br />
<label>
<%= radio_button_tag columns_name, 'all_inline' %>
<%= l(:description_all_columns) %>
</label>
</p>
<p class="buttons">
<%= submit_tag l(:button_export),
:name => nil,
:onclick => "hideModal(this);" %>
<%= submit_tag l(:button_cancel),
:name => nil,
:onclick => "hideModal(this);",
:type => 'button' %>
</p>
<% end %>
</div>
<% end %>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'common/sidebar' %>
<%= render :partial => 'tags_cloud', :object => @tags %>
<%= render_sidebar_crm_queries('contact') %>
<%= render :partial => 'notes/last_notes', :object => @last_notes %>
<%= render :partial => 'common/recently_viewed' %>
<%= call_hook(:view_contacts_sidebar_contacts_list_bottom) %>
<% end %>
<% content_for(:header_tags) do %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :contacts_sidebar, :plugin => 'redmine_contacts' %>
<%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => l(:label_contact_plural)) %>
<% end %>
<% if Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch? %>
<%= context_menu %>
<% else %>
<%= context_menu url_for( {:controller => "contacts", :action => "context_menu"} ) %>
<% end %>
@@ -0,0 +1,5 @@
<%
@tab = params[:tab_name]
@partial = params[:partial]
%>
$('#tab-placeholder-<%= @tab %>').html("<%= j(render :partial => @partial) %>")
+23
View File
@@ -0,0 +1,23 @@
<h2><%= l(:label_crm_contact_new) %></h2>
<%= labelled_form_for :contact, @contact, :url => {:action => 'create', :project_id => @project}, :html => { :multipart => true, :id => 'contact_form'} do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= render :partial => 'name_observer' %>
<%= submit_tag l(:button_save) -%>
<%= submit_tag l(:button_create_and_continue), :name => 'continue' %>
<% end -%>
<% content_for :sidebar do %>
<%= render :partial => 'common/sidebar' %>
<%= render :partial => 'contacts_duplicates/duplicates' %>
<%= render :partial => 'contacts_vcf/load' %>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag 'attachments' %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :contacts_sidebar, :plugin => 'redmine_contacts' %>
<%= robot_exclusion_tag %>
<% end %>
@@ -0,0 +1,9 @@
$('#ajax-modal').html('<%= escape_javascript(render :partial => 'contacts/new_modal') %>');
$('#ajax-modal #contact_data .extended').hide();
$('#ajax-modal #contact_data #show_details_link').show();
showModal('ajax-modal', '800px');
$('#new_contact').submit( function(event) {
$('.file_selector').val('');
event.preventDefault();
});

Some files were not shown because too many files have changed in this diff Show More