xref: /plugin/bez/mdl/Thread.php (revision a0cd8c785f18b483f73582b411767428d04a78f6)
1<?php
2
3namespace dokuwiki\plugin\bez\mdl;
4
5use dokuwiki\plugin\bez\meta\Mailer;
6use dokuwiki\plugin\bez\meta\PermissionDeniedException;
7use dokuwiki\plugin\bez\meta\ValidationException;
8
9class Thread extends Entity {
10
11    protected $id;
12
13    protected $original_poster, $coordinator, $closed_by;
14
15    protected $private, $lock;
16
17    protected $type, $state;
18
19    protected $create_date, $last_activity_date, $last_modification_date, $close_date;
20
21    protected $title, $content, $content_html;
22
23    protected $task_count, $task_count_closed, $task_sum_cost;
24
25    public static function get_columns() {
26        return array('id',
27                     'original_poster', 'coordinator', 'closed_by',
28                     'private', 'lock',
29                     'type', 'state',
30                     'create_date', 'last_activity_date', 'last_modification_date', 'close_date',
31                     'title', 'content', 'content_html',
32                     'task_count', 'task_count_closed', 'task_sum_cost');
33    }
34
35    public static function get_select_columns() {
36        $cols = parent::get_select_columns();
37        array_push($cols, 'label_id', 'label_name');
38        return $cols;
39    }
40
41    public static function get_states() {
42        return array('proposal', 'opened', 'done', 'closed', 'rejected');
43    }
44
45    public function __get($property) {
46        if($property == 'priority') {
47            return $this->$property;
48        }
49        return parent::__get($property);
50    }
51
52    public function user_is_coordinator() {
53        if ($this->coordinator === $this->model->user_nick ||
54           $this->model->get_level() >= BEZ_AUTH_ADMIN) {
55            return true;
56        }
57    }
58
59	public function __construct($model, $defaults=array()) {
60		parent::__construct($model);
61
62        $this->validator->set_rules(array(
63            'coordinator' => array(array('dw_user'), 'NULL'),
64            'title' => array(array('length', 200), 'NOT NULL'),
65            'content' => array(array('length', 10000), 'NOT NULL')
66        ));
67
68		//we've created empty object (new record)
69		if ($this->id === NULL) {
70			$this->original_poster = $this->model->user_nick;
71			$this->create_date = date('c');
72			$this->last_activity_date = $this->create_date;
73            $this->last_modification_date = $this->create_date;
74
75			$this->state = 'proposal';
76
77			$this->acl->grant('title', BEZ_PERMISSION_CHANGE);
78            $this->acl->grant('content', BEZ_PERMISSION_CHANGE);
79
80
81            if ($this->model->get_level() >= BEZ_AUTH_LEADER) {
82
83                $this->state = 'opened';
84
85                $this->acl->grant('coordinator', BEZ_PERMISSION_CHANGE);
86                $this->acl->grant('label_id', BEZ_PERMISSION_CHANGE);
87                $this->acl->grant('private', BEZ_PERMISSION_CHANGE);
88            }
89
90		} else {
91            //private threads
92            if ($this->model->level < BEZ_AUTH_ADMIN && $this->private == '1') {
93                if ($this->get_participant($this->model->user_nick) === false) {
94                    $this->acl->revoke(self::get_select_columns(), BEZ_AUTH_LEADER);
95                }
96            }
97
98		    if ($this->state == 'proposal' && $this->original_poster == $this->model->user_nick) {
99                $this->acl->grant('title', BEZ_PERMISSION_CHANGE);
100                $this->acl->grant('content', BEZ_PERMISSION_CHANGE);
101            }
102
103            if ($this->coordinator == $this->model->user_nick) {
104                $this->acl->grant('title', BEZ_PERMISSION_CHANGE);
105                $this->acl->grant('content', BEZ_PERMISSION_CHANGE);
106                $this->acl->grant('coordinator', BEZ_PERMISSION_CHANGE);
107                $this->acl->grant('label_id', BEZ_PERMISSION_CHANGE);
108                $this->acl->grant('private', BEZ_PERMISSION_CHANGE);
109
110                $this->acl->grant('state', BEZ_PERMISSION_CHANGE);
111            }
112        }
113	}
114
115	public function set_data($data, $filter=NULL) {
116        parent::set_data($data, $filter=NULL);
117
118        if (isset($data['coordinator']) && $this->state == 'proposal') {
119            $this->state = 'opened';
120        }
121
122		$this->content_html = p_render('xhtml',p_get_instructions($this->content), $ignore);
123
124        //update dates
125        $this->last_modification_date = date('c');
126        $this->last_activity_date = $this->last_modification_date;
127    }
128
129    public function set_state($state) {
130        if ($this->acl_of('state') < BEZ_PERMISSION_CHANGE) {
131            throw new PermissionDeniedException();
132        }
133
134        if (!in_array($state, array('opened', 'closed', 'rejected'))) {
135            throw new ValidationException('thread', array('state should be opened, closed or rejected'));
136        }
137
138        //nothing to do
139        if ($state == $this->state) {
140            return;
141        }
142
143        if ($state == 'closed' || $state == 'rejected') {
144            $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET state=?, closed_by=?, close_date=? WHERE id=?",
145                $state,
146                $this->model->user_nick,
147                date('c'),
148                $this->id);
149            //reopen the task
150        } else {
151            $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET state=? WHERE id=?", $state, $this->id);
152        }
153
154        $this->state = $state;
155    }
156
157    public function set_private_flag($flag) {
158        $private = '0';
159        if ($flag) {
160            $private = '1';
161        }
162
163        if ($private == $this->private) {
164            return;
165        }
166
167        $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET private=? WHERE id=?", $private, $this->id);
168
169    }
170
171	public function update_last_activity() {
172        $this->last_activity_date = date('c');
173        $this->model->sqlite->query('UPDATE thread SET last_activity_date=? WHERE id=?',
174                                    $this->last_activity_date, $this->id);
175    }
176
177    public function get_participants($filter='') {
178        if ($this->id === NULL) {
179            return array();
180        }
181
182        $sql = 'SELECT * FROM thread_participant WHERE';
183        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
184        if ($filter != '') {
185            if (!in_array($filter, $possible_flags)) {
186                throw new \Exception("unknown flag $filter");
187            }
188            $sql .= " $filter=1 AND";
189        }
190        $sql .= ' thread_id=? ORDER BY user_id';
191
192        $r = $this->model->sqlite->query($sql, $this->id);
193        $pars = $this->model->sqlite->res2arr($r);
194        $participants = array();
195        foreach ($pars as $par) {
196            $participants[$par['user_id']] = $par;
197        }
198
199        return $participants;
200    }
201
202    public function get_participant($user_id) {
203        if ($this->id === NULL) {
204            return array();
205        }
206
207        $r = $this->model->sqlite->query('SELECT * FROM thread_participant WHERE thread_id=? AND user_id=?', $this->id, $user_id);
208        $par = $this->model->sqlite->res2row($r);
209        if (!is_array($par)) {
210            return false;
211        }
212
213        return $par;
214    }
215
216    public function is_subscribent($user_id=null) {
217        if ($user_id == null) {
218            $user_id = $this->model->user_nick;
219        }
220        $par = $this->get_participant($user_id);
221        if ($par['subscribent'] == 1) {
222            return true;
223        }
224        return false;
225    }
226
227    public function remove_participant_flags($user_id, $flags) {
228        //thread not saved yet
229        if ($this->id === NULL) {
230            throw new \Exception('cannot remove flags from not saved thread');
231        }
232
233        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
234        if (array_intersect($flags, $possible_flags) != $flags) {
235            throw new \Exception('unknown flags');
236        }
237
238        $set = implode(',', array_map(function ($v) { return "$v=0"; }, $flags));
239
240        $sql = "UPDATE thread_participant SET $set WHERE thread_id=? AND user_id=?";
241        $this->model->sqlite->query($sql, $this->id, $user_id);
242
243    }
244
245	public function set_participant_flags($user_id, $flags=array()) {
246        //thread not saved yet
247        if ($this->id === NULL) {
248            throw new \Exception('cannot add flags to not saved thread');
249        }
250
251        //validate user
252        if (!$this->model->userFactory->exists($user_id)) {
253            throw new \Exception("$user_id isn't dokuwiki user");
254        }
255
256        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
257        if (array_intersect($flags, $possible_flags) != $flags) {
258            throw new \Exception('unknown flags');
259        }
260
261        $participant = $this->get_participant($user_id);
262        if ($participant == false) {
263            $participant = array_fill_keys($possible_flags, 0);
264
265            $participant['thread_id'] = $this->id;
266            $participant['user_id'] = $user_id;
267            $participant['added_by'] = $this->model->user_nick;
268            $participant['added_date'] = date('c');
269        }
270        $values = array_merge($participant, array_fill_keys($flags, 1));
271
272        $keys = join(',', array_keys($values));
273        $vals = join(',', array_fill(0,count($values),'?'));
274
275        $sql = "REPLACE INTO thread_participant ($keys) VALUES ($vals)";
276        $this->model->sqlite->query($sql, array_values($values));
277	}
278
279
280    public function invite($client) {
281        $this->set_participant_flags($client, array('subscribent'));
282        $this->mail_notify_invite($client);
283    }
284
285    public function get_labels() {
286        //record not saved
287        if ($this->id === NULL) {
288           return array();
289        }
290
291        $labels = array();
292        $r = $this->model->sqlite->query('SELECT * FROM label JOIN thread_label ON label.id = thread_label.label_id
293                                            WHERE thread_label.thread_id=?', $this->id);
294        $arr = $this->model->sqlite->res2arr($r);
295        foreach ($arr as $label) {
296            $labels[$label['id']] = $label;
297        }
298
299        return $labels;
300    }
301
302    public function add_label($label_id) {
303         //issue not saved yet
304        if ($this->id === NULL) {
305            throw new \Exception('cannot add labels to not saved thread. use initial_save() instead');
306        }
307
308        $r = $this->model->sqlite->query('SELECT id FROM label WHERE id=?', $label_id);
309        $label_id = $this->model->sqlite->res2single($r);
310        if (!$label_id) {
311            throw new \Exception("label($label_id) doesn't exist");
312        }
313
314
315        $this->model->sqlite->storeEntry('thread_label',
316                                         array('thread_id' => $this->id,
317                                               'label_id' => $label_id));
318
319    }
320
321    public function remove_label($label_id) {
322        //issue not saved yet
323        if ($this->id === NULL) {
324            throw new \Exception('cannot remove labels from not saved thread. use initial_save() instead');
325        }
326
327        /** @var \PDOStatement $r */
328        $r = $this->model->sqlite->query('DELETE FROM thread_label WHERE thread_id=? AND label_id=?',$this->id, $label_id);
329        if ($r->rowCount() != 1) {
330            throw new \Exception('label was not assigned to this thread');
331        }
332
333    }
334
335    public function get_causes() {
336        $r = $this->model->sqlite->query("SELECT id FROM thread_comment WHERE type LIKE 'cause_%' AND thread_id=?",
337                                         $this->id);
338        $arr = $this->model->sqlite->res2arr($r);
339        $causes = array();
340        foreach ($arr as $cause) {
341            $causes[] = $cause['id'];
342        }
343
344        return $causes;
345    }
346
347    public function can_add_comments() {
348        return in_array($this->state, array('proposal', 'opened', 'done'));
349    }
350
351    public function can_add_causes() {
352        return $this->type == 'issue' && in_array($this->state, array('opened', 'done'));
353    }
354
355    public function can_add_tasks() {
356        return in_array($this->state, array('opened', 'done'));
357    }
358
359    public function can_add_participants() {
360        return in_array($this->state, array('opened', 'done'));
361    }
362
363    public function can_be_closed() {
364        $res = $this->model->sqlite->query("SELECT thread_comment.id FROM thread_comment
365                               LEFT JOIN task ON thread_comment.id = task.thread_comment_id
366                               WHERE thread_comment.thread_id = ? AND
367                                     thread_comment.type LIKE 'cause_%' AND task.id IS NULL", $this->id);
368
369        $causes_without_tasks = $this->model->sqlite->res2row($res) ? true : false;
370        return $this->state == 'done' &&
371            ! $causes_without_tasks;
372
373    }
374
375    public function can_be_rejected() {
376        return $this->state != 'rejected' && $this->task_count == 0;
377    }
378
379    public function can_be_reopened() {
380        return in_array($this->state, array('closed', 'rejected'));
381    }
382
383    public function closing_comment() {
384        $r = $this->model->thread_commentFactory->get_from_thread($this, array(), 'id', true, 1);
385        $thread_comment = $r->fetch();
386
387        return $thread_comment->content_html;
388    }
389
390    //http://data.agaric.com/capture-all-sent-mail-locally-postfix
391    //https://askubuntu.com/questions/192572/how-do-i-read-local-email-in-thunderbird
392    public function mail_notify($replacements=array(), $users=false, $attachedImages=array()) {
393        $plain = io_readFile($this->model->action->localFN('thread-notification'));
394        $html = io_readFile($this->model->action->localFN('thread-notification', 'html'));
395
396        $thread_reps = array(
397                                'thread_id' => $this->id,
398                                'thread_link' => $this->model->action->url('thread', 'id', $this->id),
399                                'thread_unsubscribe' =>
400                                    $this->model->action->url('thread', 'id', $this->id, 'action', 'unsubscribe'),
401                                'custom_content' => false,
402                                'action_border_color' => 'transparent',
403                                'action_color' => 'transparent',
404                           );
405
406        //$replacements can override $issue_reps
407        $rep = array_merge($thread_reps, $replacements);
408        //auto title
409        if (!isset($rep['subject'])) {
410            $rep['subject'] =  '#'.$this->id. ' ' .$this->title;
411        }
412        if (!isset($rep['content_html'])) {
413            $rep['content_html'] = $rep['content'];
414        }
415        if (!isset($rep['who_full_name'])) {
416            $rep['who_full_name'] =
417                $this->model->userFactory->get_user_full_name($rep['who']);
418        }
419
420        //format when
421        $rep['when'] =  dformat(strtotime($rep['when']), '%Y-%m-%d %H:%M');
422
423        if ($rep['custom_content'] === false) {
424            $html = str_replace('@CONTENT_HTML@', '
425                <div style="margin: 5px 0;">
426                    <strong>@WHO_FULL_NAME@</strong> <br>
427                    <span style="color: #888">@WHEN@</span>
428                </div>
429                @CONTENT_HTML@
430            ', $html);
431        }
432
433        //we must do it manually becouse Mailer uses htmlspecialchars()
434        $html = str_replace('@CONTENT_HTML@', $rep['content_html'], $html);
435
436        $mailer = new Mailer();
437        $mailer->setBody($plain, $rep, $rep, $html, false);
438
439        if ($users == FALSE) {
440
441            $users = $this->get_participants('subscribent');
442            //don't notify myself
443            unset($users[$this->model->user_nick]);
444        }
445
446        $emails = array_map(function($user) {
447            if (is_array($user)) {
448                $user = $user['user_id'];
449            }
450            return $this->model->userFactory->get_user_email($user);
451        }, $users);
452
453
454        $mailer->to($emails);
455        $mailer->subject($rep['subject']);
456
457        foreach ($attachedImages as $img) {
458            $mailer->attachFile($img['path'], $img['mime'], $img['name'], $img['embed']);
459        }
460
461        $send = $mailer->send();
462        if ($send === false) {
463            //this may mean empty $emails
464            //throw new Exception("can't send email");
465        }
466    }
467
468    protected function mail_issue_box_reps(&$replacements, &$attachedImages) {
469        $replacements['custom_content'] = true;
470
471        $html =  '<h2 style="font-size: 1.2em;">';
472	    $html .=    '<a style="font-size:115%" href="@THREAD_LINK@">#@THREAD_ID@</a> ';
473
474        if ( ! empty($this->type_string)) {
475            $html .= $this->type_string;
476        } else {
477            $html .= '<i style="color: #777"> '.
478                        $this->model->action->getLang('issue_type_no_specified').
479                    '</i>';
480        }
481
482        $html .= ' ('. $this->model->action->getLang('state_' . $this->state ) .') ';
483
484        $html .= '<span style="color: #777; font-weight: normal; font-size: 90%;">';
485        $html .= $this->model->action->getLang('coordinator') . ': ';
486        $html .= '<span style="font-weight: bold;">';
487
488        if ($this->state == 'proposal') {
489            $html .= '<i style="font-weight: normal;">' .
490                $this->model->action->getLang('proposal') .
491                '</i>';
492        } else {
493            $html .= $this->model->userFactory->get_user_full_name($this->coordinator);
494        }
495        $html .= '</span></span></h2>';
496
497        $html .= '<h2 style="font-size: 1.2em;border-bottom: 1px solid @ACTION_BORDER_COLOR@">' . $this->title . '</h2>';
498
499        $html .= p_render('bez_xhtmlmail', p_get_instructions($this->content), $info);
500        $attachedImages = array_merge($attachedImages, $info['img']);
501
502        $replacements['content_html'] = $html;
503
504
505         switch ($this->priority) {
506            case '0':
507                $replacements['action_color'] = '#F8E8E8';
508                $replacements['action_border_color'] = '#F0AFAD';
509                break;
510            case '1':
511                $replacements['action_color'] = '#ffd';
512                $replacements['action_border_color'] = '#dd9';
513                break;
514            case '2':
515                $replacements['action_color'] = '#EEF6F0';
516                $replacements['action_border_color'] = '#B0D2B6';
517                break;
518            case 'None':
519                $replacements['action_color'] = '#e7f1ff';
520                $replacements['action_border_color'] = '#a3c8ff';
521                break;
522            default:
523                $replacements['action_color'] = '#fff';
524                $replacements['action_border_color'] = '#bbb';
525                break;
526        }
527    }
528
529    public function mail_notify_change_state() {
530        $replacements = array(
531            'who' => $this->model->user_nick,
532            'action' => $this->model->action->getLang('mail_mail_notify_change_state_action')
533        );
534        $attachedImages = array();
535        $this->mail_issue_box_reps($replacements, $attachedImages);
536        $this->mail_notify($replacements, false, $attachedImages);
537    }
538
539    public function mail_notify_invite($client) {
540        $replacements = array(
541            'who' => $this->model->user_nick,
542            'action' => $this->model->action->getLang('mail_mail_notify_invite_action')
543        );
544        $attachedImages = array();
545        $this->mail_issue_box_reps($replacements, $attachedImages);
546        $this->mail_notify($replacements, array($client), $attachedImages);
547    }
548
549    public function mail_inform_coordinator() {
550        $replacements = array(
551            'who' => $this->model->user_nick,
552            'action' => $this->model->action->getLang('mail_mail_inform_coordinator_action')
553        );
554        $attachedImages = array();
555        $this->mail_issue_box_reps($replacements, $attachedImages);
556        $this->mail_notify($replacements, array($this->coordinator), $attachedImages);
557    }
558
559    public function mail_notify_issue_inactive($users=false) {
560        $replacements = array(
561            'who' => '',
562            'action' => $this->model->action->getLang('mail_mail_notify_issue_inactive')
563        );
564        $attachedImages = array();
565        $this->mail_issue_box_reps($replacements, $attachedImages);
566        $this->mail_notify($replacements, $users, $attachedImages);
567    }
568
569}
570