Initial Redmine tooling and local plugin forks
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
/.cache/
|
||||||
|
/__pycache__/
|
||||||
|
/redmine-copy/
|
||||||
|
/dist/*.tar.gz
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+13
@@ -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.
|
||||||
Vendored
+14
@@ -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`.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
gem "redmine_crm"
|
||||||
|
gem "vcard", "~> 0.2.8"
|
||||||
|
gem "spreadsheet", "~> 0.6.8"
|
||||||
@@ -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 ? (' ' * 2 * level + '» ') : '')
|
||||||
|
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
@@ -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('> ','').gsub('"', '"')
|
||||||
|
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
|
||||||
@@ -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
@@ -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);"> </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);"> </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();
|
||||||
@@ -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
@@ -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) %>")
|
||||||
@@ -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
Reference in New Issue
Block a user