Completed
Push — master ( 8849ee...a1b70f )
by Andreas
24:35
created

org_openpsa_directmarketing_sender   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Test Coverage

Coverage 40.86%

Importance

Changes 3
Bugs 0 Features 1
Metric Value
eloc 183
dl 0
loc 391
ccs 76
cts 186
cp 0.4086
rs 8.4
c 3
b 0
f 1
wmc 50

15 Methods

Rating   Name   Duplication   Size   Complexity  
A send_test() 0 12 3
A _qb_common_constraints() 0 18 2
A send_bg() 0 31 5
A _create_token() 0 16 4
A get_status() 0 14 1
A register_send_job() 0 15 3
A _get_person() 0 19 4
A _qb_send_loop() 0 7 1
A _qb_chunk_limits() 0 10 2
A _qb_filter_results() 0 27 4
B _send_member() 0 34 8
A _qb_single_chunk() 0 33 5
A process_results() 0 9 3
A __construct() 0 13 3
A _check_campaign_up_to_date() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like org_openpsa_directmarketing_sender often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use org_openpsa_directmarketing_sender, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @package org.openpsa.directmarketing
4
 * @author CONTENT CONTROL http://www.contentcontrol-berlin.de/
5
 * @copyright CONTENT CONTROL http://www.contentcontrol-berlin.de/
6
 * @license http://www.gnu.org/licenses/gpl.html GNU General Public License
7
 */
8
9
/**
10
 * Campaign message sender
11
 *
12
 * @package org.openpsa.directmarketing
13
 */
14
class org_openpsa_directmarketing_sender extends midcom_baseclasses_components_purecode
15
{
16
    /**
17
     * Are we running in test mode
18
     *
19
     * @var boolean
20
     */
21
    private $test_mode = false;
22
23
    /**
24
     * How many messages to send in one go
25
     *
26
     * @var integer
27
     */
28
    public $chunk_size = 50;
29
30
    /**
31
     * Length of the message token
32
     *
33
     * @var integer
34
     */
35
    public $token_size = 15;
36
37
    /**
38
     * The message we're working on
39
     *
40
     * @var org_openpsa_directmarketing_campaign_message_dba
41
     */
42
    private $_message;
43
44
    /**
45
     * The backend to use
46
     *
47
     * @var org_openpsa_directmarketing_sender_backend
48
     */
49
    private $_backend;
50
51
    /**
52
     * Tracks total number of sent messages
53
     *
54
     * @var integer
55
     */
56
    private static $_messages_sent = 0;
57
58
    private $_chunk_num = 0;
59
60
    /**
61
     * How many times to recurse if all results are filtered (speed vs memory [and risk on crashing], higher is faster)
62
     *
63
     * @var integer
64
     */
65
    private $_chunk_max_recurse = 15;
66
67
    private $from;
68
69
    private $subject;
70
71
    /**
72
     * @param org_openpsa_directmarketing_campaign_message_dba $message The message we're working on
73
     * @param array $config Configuration that gets handed to the backend
74
     */
75 3
    public function __construct(org_openpsa_directmarketing_campaign_message_dba $message, array $config = [], $from = '', $subject = '')
76
    {
77 3
        parent::__construct();
78 3
        $this->_message = $message;
79 3
        $this->from = $from;
80 3
        $this->subject = $subject;
81
82 3
        if (   $this->_message->orgOpenpsaObtype != org_openpsa_directmarketing_campaign_message_dba::EMAIL_TEXT
83 3
            && $this->_message->orgOpenpsaObtype != org_openpsa_directmarketing_campaign_message_dba::EMAIL_HTML) {
84
            throw new midcom_error('unsupported message type');
85
        }
86 3
        $this->_backend = new org_openpsa_directmarketing_sender_backend_email($config, $this->_message);
87 3
        $this->chunk_size = $this->_config->get('chunk_size');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_config->get('chunk_size') can also be of type false. However, the property $chunk_size is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
88 3
    }
89
90
    /**
91
     * Sends a message to all testers
92
     */
93 1
    public function send_test($content)
94
    {
95 1
        $this->test_mode = true;
96 1
        midcom::get()->disable_limits();
97
98 1
        while ($results = $this->_qb_send_loop()) {
99
            if (!$this->process_results($results, $content)) {
100
                return false;
101
            }
102
        }
103
104 1
        return true;
105
    }
106
107
    /**
108
     * Sends $content to all members of the campaign
109
     */
110 1
    public function send_bg($url_base, $batch, $content)
111
    {
112
        //TODO: Figure out how to recognize errors and pass the info on
113
114 1
        $this->_chunk_num = $batch - 1;
115
116 1
        midcom::get()->disable_limits();
117
        //For first batch (they start from 1 instead of 0) make sure we have smart campaign members up to date
118 1
        if ($batch == 1) {
119
            $this->_check_campaign_up_to_date();
120
        }
121
        // Register sendStarted if not already set
122 1
        if (!$this->_message->sendStarted) {
123 1
            $this->_message->sendStarted = time();
124 1
            $this->_message->update();
125
        }
126 1
        $results = $this->_qb_single_chunk();
127
        //The method above might have incremented the counter for internal reasons
128 1
        $batch = $this->_chunk_num + 1;
129 1
        if ($results === false) {
130 1
            $status = true; //All should be ok
131
        } elseif ($status = $this->process_results($results, $content)) {
132
            //register next batch
133
            return $this->register_send_job($batch + 1, $url_base);
134
        }
135
136
        // Last batch done, register sendCompleted if we're not in test mode
137 1
        $this->_message->sendCompleted = time();
138 1
        $this->_message->update();
139
140 1
        return $status;
141
    }
142
143
    private function process_results(array $results, $content)
144
    {
145
        if (!$this->_backend->check_results($results)) {
146
            return false; //Backend refuses delivery
147
        }
148
        foreach ($results as $member) {
149
            $this->_send_member($member, $content);
150
        }
151
        return true;
152
    }
153
154
    public function register_send_job($batch, $url_base, $time = null)
155
    {
156
        $time = $time ?: time() + 30;
157
        $args = [
158
            'batch' => $batch,
159
            'url_base' => $url_base,
160
        ];
161
        debug_add("Registering batch #{$args['batch']} for {$args['url_base']} to start on: " . date('Y-m-d H:i:s', $time));
162
        midcom::get()->auth->request_sudo('org.openpsa.directmarketing');
163
        $atstat = midcom_services_at_interface::register($time, 'org.openpsa.directmarketing', 'background_send_message', $args);
164
        midcom::get()->auth->drop_sudo();
165
        if (!$atstat) {
166
            debug_add("FAILED to register batch #{$args['batch']} for {$args['url_base']}, errstr: " . midcom_connection::get_error_string(), MIDCOM_LOG_ERROR);
167
        }
168
        return $atstat;
169
    }
170
171
    private function _send_member(org_openpsa_directmarketing_campaign_member_dba $member, $content)
172
    {
173
        if (!$person = $this->_get_person($member)) {
174
            return;
175
        }
176
177
        $from = $this->from ?: '[email protected]';
178
        $subject = $this->subject ?: '[no subject]';
179
        if ($this->test_mode) {
180
            $subject = "[TEST] {$subject}";
181
        }
182
        $content = $member->personalize_message($content, $this->_message->orgOpenpsaObtype, $person);
183
        $token = $this->_create_token();
184
        $subject = $member->personalize_message($subject, org_openpsa_directmarketing_campaign_message_dba::EMAIL_TEXT, $person);
185
        $params = [];
186
187
        try {
188
            $this->_backend->send($person, $member, $token, $subject, $content, $from);
189
            self::$_messages_sent++;
190
            $status = org_openpsa_directmarketing_campaign_messagereceipt_dba::SENT;
191
        } catch (midcom_error $e) {
192
            $status = org_openpsa_directmarketing_campaign_messagereceipt_dba::FAILURE;
193
            if (!$this->test_mode) {
194
                $params[] = [
195
                    'domain' => 'org.openpsa.directmarketing',
196
                    'name' => 'send_error_message',
197
                    'value' => $e->getMessage(),
198
                ];
199
            } else {
200
                midcom::get()->uimessages->add($this->_l10n->get($this->_component), $e->getMessage(), 'error');
201
            }
202
        }
203
        if (!$this->test_mode) {
204
            $member->create_receipt($this->_message->id, $status, $token, $params);
205
        }
206
    }
207
208
    /**
209
     * Creates a random token string that can be used to track a single
210
     * delivery. The returned token string will only contain
211
     * lowercase alphanumeric characters and will start with a lowercase
212
     * letter to avoid problems with special processing being triggered
213
     * by special characters in the token string.
214
     *
215
     * @return string random token string
216
     */
217
    private function _create_token()
218
    {
219
        //Testers need dummy token
220
        if ($this->test_mode) {
221
            return 'dummy';
222
        }
223
224
        $token = midcom_helper_misc::random_string(1, 'abcdefghijklmnopqrstuvwxyz');
225
        $token .= midcom_helper_misc::random_string($this->token_size - 1, 'abcdefghijklmnopqrstuvwxyz0123456789');
226
227
        //If token is not free or (very, very unlikely) matches our dummy token, recurse.
228
        if (   $token === 'dummy'
229
            || !org_openpsa_directmarketing_campaign_messagereceipt_dba::token_is_free($token)) {
230
            return $this->_create_token();
231
        }
232
        return $token;
233
    }
234
235
    /**
236
     * Check is given member has denied contacts of given type
237
     *
238
     * @param org_openpsa_directmarketing_campaign_member_dba $member campaign_member object related to the person
239
     * @return mixed org_openpsa_contacts_person_dba person on success, false if denied
240
     */
241
    private function _get_person(org_openpsa_directmarketing_campaign_member_dba $member)
242
    {
243
        try {
244
            $person = org_openpsa_contacts_person_dba::get_cached($member->person);
245
        } catch (midcom_error $e) {
246
            debug_add("Person #{$member->person} deleted or missing, removing member (member #{$member->id})");
247
            $member->orgOpenpsaObtype = org_openpsa_directmarketing_campaign_member_dba::UNSUBSCRIBED;
248
            $member->delete();
249
            return false;
250
        }
251
        $type = $this->_backend->get_type();
252
        if (   $person->get_parameter('org.openpsa.directmarketing', "send_all_denied")
253
            || $person->get_parameter('org.openpsa.directmarketing', "send_{$type}_denied")) {
254
            debug_add("Sending {$type} messages to person {$person->rname} is denied, unsubscribing member (member #{$member->id})");
0 ignored issues
show
Bug Best Practice introduced by
The property rname does not exist on midcom_core_dbaobject. Since you implemented __get, consider adding a @property annotation.
Loading history...
255
            $member->orgOpenpsaObtype = org_openpsa_directmarketing_campaign_member_dba::UNSUBSCRIBED;
256
            $member->update();
257
            return false;
258
        }
259
        return $person;
260
    }
261
262
    /**
263
     * Loops trough send filter in chunks, adds some common constraints and checks for send-receipts.
264
     */
265 1
    private function _qb_send_loop()
266
    {
267 1
        $ret = $this->_qb_single_chunk();
268 1
        $this->_chunk_num++;
269
        //Trivial rate limiting
270 1
        sleep(1);
271 1
        return $ret;
272
    }
273
274 2
    private function _qb_single_chunk($level = 0)
275
    {
276 2
        $qb = org_openpsa_directmarketing_campaign_member_dba::new_query_builder();
277 2
        $this->_backend->add_member_constraints($qb);
278 2
        $this->_qb_common_constraints($qb);
279 2
        $this->_qb_chunk_limits($qb);
280
281 2
        $results = $qb->execute_unchecked();
282 2
        if (empty($results)) {
283 2
            debug_add('Got failure or empty resultset, aborting');
284 2
            return false;
285
        }
286
287
        if ($this->test_mode) {
288
            debug_add('TEST mode, no receipt filtering will be done');
289
            return $results;
290
        }
291
        debug_add('Got ' . count($results) . ' initial results');
292
293
        $results = $this->_qb_filter_results($results);
294
295
        debug_add('Have ' . count($results) . ' results left after filtering');
296
        debug_add("Recursion level is {$level}, limit is {$this->_chunk_max_recurse}");
297
        /* Make sure we still have results left, if not just recurse...
298
         (basically this is to avoid returning an empty array when everything is otherwise ok) */
299
        if (empty($results) && ($level < $this->_chunk_max_recurse)) {
300
            debug_add('All our results got filtered, recursing for another round');
301
            $this->_chunk_num++;
302
            return $this->_qb_single_chunk($level + 1);
303
        }
304
305
        reset($results);
306
        return $results;
307
    }
308
309 2
    private function _qb_chunk_limits($qb)
310
    {
311 2
        debug_add("Processing chunk {$this->_chunk_num}");
312 2
        $offset = $this->_chunk_num * $this->chunk_size;
313 2
        if ($offset > 0) {
314 1
            debug_add("Setting offset to {$offset}");
315 1
            $qb->set_offset($offset);
316
        }
317 2
        debug_add("Setting limit to {$this->chunk_size}");
318 2
        $qb->set_limit($this->chunk_size);
319 2
    }
320
321
    private function _qb_filter_results($results)
322
    {
323
        if (empty($results)) {
324
            return $results;
325
        }
326
        //Make a map for receipt filtering
327
        $results_person_map = [];
328
        foreach ($results as $k => $member) {
329
            $results_person_map[$member->person] = $k;
330
        }
331
        $mc = org_openpsa_directmarketing_campaign_messagereceipt_dba::new_collector('message', $this->_message->id);
332
        $mc->add_constraint('message', '=', $this->_message->id);
333
        $mc->add_constraint('orgOpenpsaObtype', '=', org_openpsa_directmarketing_campaign_messagereceipt_dba::SENT);
334
        $mc->add_constraint('person', 'IN', array_keys($results_person_map));
335
336
        $receipts = $mc->get_values('person');
337
338
        if (empty($receipts)) {
339
            return $results;
340
        }
341
        debug_add('Found ' . count($receipts) . ' send receipts for this chunk');
342
343
        //Filter results array by receipt
344
        $receipts = array_flip($receipts);
345
        $persons_to_remove = array_intersect_key($results_person_map, $receipts);
346
        $results = array_diff_key($results, array_flip($persons_to_remove));
347
        return $results;
348
    }
349
350
    /**
351
     * Get send status
352
     *
353
     * @return array Number of valid members at index 0 and number of send receipts at 1
354
     */
355 1
    public function get_status()
356
    {
357 1
        $qb_mem = org_openpsa_directmarketing_campaign_member_dba::new_query_builder();
358 1
        $this->_backend->add_member_constraints($qb_mem);
359
360 1
        $this->_qb_common_constraints($qb_mem);
361 1
        $valid_members = $qb_mem->count_unchecked();
362
363 1
        $qb_receipts = org_openpsa_directmarketing_campaign_messagereceipt_dba::new_query_builder();
364 1
        $qb_receipts->add_constraint('message', '=', $this->_message->id);
365 1
        $qb_receipts->add_constraint('orgOpenpsaObtype', '=', org_openpsa_directmarketing_campaign_messagereceipt_dba::SENT);
366 1
        $send_receipts = $qb_receipts->count_unchecked();
367
368 1
        return [$valid_members, $send_receipts];
369
    }
370
371
    /**
372
     * Check if this message is attached to a smart campaign, if so update the campaign members
373
     */
374
    private function _check_campaign_up_to_date()
375
    {
376
        midcom::get()->auth->request_sudo('org.openpsa.directmarketing');
377
        $campaign = new org_openpsa_directmarketing_campaign_dba($this->_message->campaign);
378
        midcom::get()->auth->drop_sudo();
379
        if ($campaign->orgOpenpsaObtype == org_openpsa_directmarketing_campaign_dba::TYPE_SMART) {
380
            $campaign->update_smart_campaign_members();
381
        }
382
    }
383
384
    /**
385
     * Sets the common constrains for campaign members queries
386
     */
387 3
    protected function _qb_common_constraints($qb)
388
    {
389 3
        debug_add("Setting constraint campaign = {$this->_message->campaign}");
390 3
        $qb->add_constraint('campaign', '=', $this->_message->campaign);
391 3
        $qb->add_constraint('suspended', '<', time());
392 3
        if ($this->test_mode) {
393 1
            debug_add('TEST mode, adding constraints');
394 1
            $qb->add_constraint('orgOpenpsaObtype', '=', org_openpsa_directmarketing_campaign_member_dba::TESTER);
395
        } else {
396 2
            debug_add('REAL mode, adding constraints');
397
            //Fail safe way, exclude those we know we do not want, in case some wanted members have incorrect type...
398 2
            $qb->add_constraint('orgOpenpsaObtype', '<>', org_openpsa_directmarketing_campaign_member_dba::TESTER);
399 2
            $qb->add_constraint('orgOpenpsaObtype', '<>', org_openpsa_directmarketing_campaign_member_dba::UNSUBSCRIBED);
400 2
            $qb->add_constraint('orgOpenpsaObtype', '<>', org_openpsa_directmarketing_campaign_member_dba::BOUNCED);
401
        }
402 3
        $qb->add_order('person.lastname', 'ASC');
403 3
        $qb->add_order('person.firstname', 'ASC');
404 3
        $qb->add_order('person.id', 'ASC');
405 3
    }
406
}
407