Improved new export-import by adding invoices.
[timetracker.git] / WEB-INF / lib / ttOrgImportHelper.class.php
1 <?php
2 // +----------------------------------------------------------------------+
3 // | Anuko Time Tracker
4 // +----------------------------------------------------------------------+
5 // | Copyright (c) Anuko International Ltd. (https://www.anuko.com)
6 // +----------------------------------------------------------------------+
7 // | LIBERAL FREEWARE LICENSE: This source code document may be used
8 // | by anyone for any purpose, and freely redistributed alone or in
9 // | combination with other software, provided that the license is obeyed.
10 // |
11 // | There are only two ways to violate the license:
12 // |
13 // | 1. To redistribute this code in source form, with the copyright
14 // |    notice or license removed or altered. (Distributing in compiled
15 // |    forms without embedded copyright notices is permitted).
16 // |
17 // | 2. To redistribute modified versions of this code in *any* form
18 // |    that bears insufficient indications that the modifications are
19 // |    not the work of the original author(s).
20 // |
21 // | This license applies to this document only, not any other software
22 // | that it may be combined with.
23 // |
24 // +----------------------------------------------------------------------+
25 // | Contributors:
26 // | https://www.anuko.com/time_tracker/credits.htm
27 // +----------------------------------------------------------------------+
28
29 import('ttUserHelper');
30 import('ttRoleHelper');
31 import('ttTaskHelper');
32 import('ttProjectHelper');
33 import('ttClientHelper');
34 import('ttInvoiceHelper');
35
36 // ttOrgImportHelper - this class is a future replacement for ttImportHelper.
37 // Currently, it is work in progress.
38 // When done, it should handle import of complex groups consisting of other groups.
39 class ttOrgImportHelper {
40   var $errors               = null; // Errors go here. Set in constructor by reference.
41   var $conflicting_entities = null; // A comma-separated list of entity names we cannot import.
42   var $canImport      = true;    // False if we cannot import data due to a conflict such as login collision.
43   var $firstPass      = true;    // True during first pass through the file.
44   var $org_id         = null;    // Organization id (same as top group_id).
45   var $current_group_id        = null; // Current group id during parsing.
46   var $current_parent_group_id = null; // Current parent group id during parsing.
47                                        // Set when we create a new group.
48   var $top_role_id    = 0;       // Top role id.
49
50   // Entity maps for current group. They map XML ids with database ids.
51   var $currentGroupRoleMap    = array();
52   var $currentGroupTaskMap    = array();
53   var $currentGroupProjectMap = array();
54   var $currentGroupClientMap  = array();
55   var $currentGroupUserMap    = array();
56   var $currentGroupInvoiceMap = array();
57
58   // Constructor.
59   function __construct(&$errors) {
60     $this->errors = &$errors;
61     $this->top_role_id = ttRoleHelper::getRoleByRank(512, 0);
62   }
63
64   // startElement - callback handler for opening tag of an XML element in the file.
65   function startElement($parser, $name, $attrs) {
66     global $i18n;
67
68     // First pass. We only check user logins for potential collisions with existing.
69     if ($this->firstPass) {
70       if ($name == 'USER' && $this->canImport) {
71         $login = $attrs['LOGIN'];
72         if ('' != $attrs['STATUS'] && ttUserHelper::getUserByLogin($login)) {
73           // We have a login collision. Append colliding login to a list of things we cannot import.
74           $this->conflicting_entities .= ($this->conflicting_entities ? ", $login" : $login);
75         }
76       }
77     }
78
79     // Second pass processing. We import data here, one tag at a time.
80     if (!$this->firstPass && $this->canImport && $this->errors->no()) {
81       $mdb2 = getConnection();
82
83       // We are in second pass and can import data.
84       if ($name == 'GROUP') {
85         // Create a new group.
86         $this->current_group_id = $this->createGroup(array(
87           'parent_id' => $this->current_parent_group_id,
88           'org_id' => $this->org_id,
89           'name' => $attrs['NAME'],
90           'currency' => $attrs['CURRENCY'],
91           'decimal_mark' => $attrs['DECIMAL_MARK'],
92           'lang' => $attrs['LANG'],
93           'date_format' => $attrs['DATE_FORMAT'],
94           'time_format' => $attrs['TIME_FORMAT'],
95           'week_start' => $attrs['WEEK_START'],
96           'tracking_mode' => $attrs['TRACKING_MODE'],
97           'project_required' => $attrs['PROJECT_REQUIRED'],
98           'task_required' => $attrs['TASK_REQUIRED'],
99           'record_type' => $attrs['RECORD_TYPE'],
100           'bcc_email' => $attrs['BCC_EMAIL'],
101           'allow_ip' => $attrs['ALLOW_IP'],
102           'password_complexity' => $attrs['PASSWORD_COMPLEXITY'],
103           'plugins' => $attrs['PLUGINS'],
104           'lock_spec' => $attrs['LOCK_SPEC'],
105           'workday_minutes' => $attrs['WORKDAY_MINUTES'],
106           'custom_logo' => $attrs['CUSTOM_LOGO'],
107           'config' => $attrs['CONFIG']));
108
109         // Special handling for top group.
110         if (!$this->org_id && $this->current_group_id) {
111           $this->org_id = $this->current_group_id;
112           $sql = "update tt_groups set org_id = $this->current_group_id where org_id is NULL and id = $this->current_group_id";
113           $affected = $mdb2->exec($sql);
114         }
115         // Set parent group to create subgroups with this group as parent at next entry here.
116         $this->current_parent_group_id = $this->current_group_id;
117       }
118
119       if ($name == 'ROLES') {
120         // If we get here, we have to recycle $currentGroupRoleMap.
121         unset($this->currentGroupRoleMap);
122         $this->currentGroupRoleMap = array();
123         // Role map is reconstructed after processing <role> elements in XML. See below.
124       }
125
126       if ($name == 'ROLE') {
127         // We get here when processing <role> tags for the current group.
128         $role_id = ttRoleHelper::insert(array(
129               'group_id' => $this->current_group_id,
130               'org_id' => $this->org_id,
131               'name' => $attrs['NAME'],
132               'description' => $attrs['DESCRIPTION'],
133               'rank' => $attrs['RANK'],
134               'rights' => $attrs['RIGHTS'],
135               'status' => $attrs['STATUS']));
136         if ($role_id) {
137           // Add a mapping.
138           $this->currentGroupRoleMap[$attrs['ID']] = $role_id;
139         } else $this->errors->add($i18n->get('error.db'));
140       }
141
142       if ($name == 'TASKS') {
143         // If we get here, we have to recycle $currentGroupTaskMap.
144         unset($this->currentGroupTaskMap);
145         $this->currentGroupTaskMap = array();
146         // Task map is reconstructed after processing <task> elements in XML. See below.
147       }
148
149       if ($name == 'TASK') {
150         // We get here when processing <task> tags for the current group.
151         $task_id = ttTaskHelper::insert(array(
152           'group_id' => $this->current_group_id,
153           'org_id' => $this->org_id,
154           'name' => $attrs['NAME'],
155           'description' => $attrs['DESCRIPTION'],
156           'status' => $attrs['STATUS']));
157         if ($task_id) {
158           // Add a mapping.
159           $this->currentGroupTaskMap[$attrs['ID']] = $task_id;
160         } else $this->errors->add($i18n->get('error.db'));
161       }
162
163       if ($name == 'PROJECTS') {
164         // If we get here, we have to recycle $currentGroupProjectMap.
165         unset($this->currentGroupProjectMap);
166         $this->currentGroupProjectMap = array();
167         // Project map is reconstructed after processing <project> elements in XML. See below.
168       }
169
170       if ($name == 'PROJECT') {
171         // We get here when processing <project> tags for the current group.
172
173         // Prepare a list of task ids.
174         $tasks = explode(',', $attrs['TASKS']);
175         foreach ($tasks as $id)
176           $mapped_tasks[] = $this->currentGroupTaskMap[$id];
177
178         $project_id = ttProjectHelper::insert(array(
179           'group_id' => $this->current_group_id,
180           'org_id' => $this->org_id,
181           'name' => $attrs['NAME'],
182           'description' => $attrs['DESCRIPTION'],
183           'tasks' => $mapped_tasks,
184           'status' => $attrs['STATUS']));
185         if ($project_id) {
186           // Add a mapping.
187           $this->currentGroupProjectMap[$attrs['ID']] = $project_id;
188         } else $this->errors->add($i18n->get('error.db'));
189       }
190
191       if ($name == 'CLIENTS') {
192         // If we get here, we have to recycle $currentGroupClientMap.
193         unset($this->currentGroupClientMap);
194         $this->currentGroupClientMap = array();
195         // Client map is reconstructed after processing <client> elements in XML. See below.
196       }
197
198       if ($name == 'CLIENT') {
199         // We get here when processing <client> tags for the current group.
200
201         // Prepare a list of project ids.
202         $projects = explode(',', $attrs['PROJECTS']);
203         foreach ($projects as $id)
204           $mapped_projects[] = $this->currentGroupProjectMap[$id];
205
206         $client_id = ttClientHelper::insert(array(
207           'group_id' => $this->current_group_id,
208           'org_id' => $this->org_id,
209           'name' => $attrs['NAME'],
210           'address' => $attrs['ADDRESS'],
211           'tax' => $attrs['TAX'],
212           'projects' => $mapped_projects,
213           'status' => $attrs['STATUS']));
214         if ($client_id) {
215           // Add a mapping.
216           $this->currentGroupClientMap[$attrs['ID']] = $client_id;
217         } else $this->errors->add($i18n->get('error.db'));
218       }
219
220       if ($name == 'USERS') {
221         // If we get here, we have to recycle $currentGroupUserMap.
222         unset($this->currentGroupUserMap);
223         $this->currentGroupUserMap = array();
224         // User map is reconstructed after processing <user> elements in XML. See below.
225       }
226
227       if ($name == 'USER') {
228         // We get here when processing <user> tags for the current group.
229
230         $role_id = $attrs['ROLE_ID'] === '0' ? $this->top_role_id :  $this->currentGroupRoleMap[$attrs['ROLE_ID']]; // 0 (not null) means top manager role.
231
232         $user_id = ttUserHelper::insert(array(
233           'group_id' => $this->current_group_id,
234           'org_id' => $this->org_id,
235           'role_id' => $role_id,
236           'client_id' => $this->currentGroupClientMap[$attrs['CLIENT_ID']],
237           'name' => $attrs['NAME'],
238           'login' => $attrs['LOGIN'],
239           'password' => $attrs['PASSWORD'],
240           'rate' => $attrs['RATE'],
241           'email' => $attrs['EMAIL'],
242           'status' => $attrs['STATUS']), false);
243         if ($user_id) {
244           // Add a mapping.
245           $this->currentGroupUserMap[$attrs['ID']] = $user_id;
246         } else $this->errors->add($i18n->get('error.db'));
247       }
248
249       if ($name == 'USER_PROJECT_BIND') {
250         if (!ttUserHelper::insertBind(array(
251           'user_id' => $this->currentGroupUserMap[$attrs['USER_ID']],
252           'project_id' => $this->currentGroupProjectMap[$attrs['PROJECT_ID']],
253           'group_id' => $this->current_group_id,
254           'org_id' => $this->org_id,
255           'rate' => $attrs['RATE'],
256           'status' => $attrs['STATUS']))) {
257           $this->errors->add($i18n->get('error.db'));
258         }
259       }
260
261       if ($name == 'INVOICES') {
262         // If we get here, we have to recycle $currentGroupInvoiceMap.
263         unset($this->currentGroupInvoiceMap);
264         $this->currentGroupInvoiceMap = array();
265         // Invoice map is reconstructed after processing <invoice> elements in XML. See below.
266       }
267
268       if ($name == 'INVOICE') {
269         // We get here when processing <invoice> tags for the current group.
270         $invoice_id = ttInvoiceHelper::insert(array(
271           'group_id' => $this->current_group_id,
272           'org_id' => $this->org_id,
273           'name' => $attrs['NAME'],
274           'date' => $attrs['DATE'],
275           'client_id' => $this->currentGroupClientMap[$attrs['CLIENT_ID']],
276           'status' => $attrs['STATUS']));
277         if ($invoice_id) {
278           // Add a mapping.
279           $this->currentGroupInvoiceMap[$attrs['ID']] = $invoice_id;
280         } else $this->errors->add($i18n->get('error.db'));
281       }
282     }
283   }
284
285   // importXml - uncompresses the file, reads and parses its content. During parsing,
286   // startElement, endElement, and dataElement functions are called as many times as necessary.
287   // Actual import occurs in the endElement handler.
288   function importXml() {
289     global $i18n;
290
291     // Do we have a compressed file?
292     $compressed = false;
293     $file_ext = substr($_FILES['xmlfile']['name'], strrpos($_FILES['xmlfile']['name'], '.') + 1);
294     if (in_array($file_ext, array('bz','tbz','bz2','tbz2'))) {
295       $compressed = true;
296     }
297
298     // Create a temporary file.
299     $dirName = dirname(TEMPLATE_DIR . '_c/.');
300     $filename = tempnam($dirName, 'import_');
301
302     // If the file is compressed - uncompress it.
303     if ($compressed) {
304       if (!$this->uncompress($_FILES['xmlfile']['tmp_name'], $filename)) {
305         $this->errors->add($i18n->get('error.sys'));
306         return;
307       }
308       unlink($_FILES['xmlfile']['tmp_name']);
309     } else {
310       if (!move_uploaded_file($_FILES['xmlfile']['tmp_name'], $filename)) {
311         $this->errors->add($i18n->get('error.upload'));
312         return;
313       }
314     }
315
316     // Initialize XML parser.
317     $parser = xml_parser_create();
318     xml_set_object($parser, $this);
319     xml_set_element_handler($parser, 'startElement', false);
320
321     // We need to parse the file 2 times:
322     //   1) First pass: determine if import is possible - there must be no login collisions.
323     //   2) Second pass: if we can import, then do import in a second pass.
324     // This is different from earlier approach for single group import, where we could
325     // do both things in one pass because user info was in the beginning of XML file.
326     // Now, with subgroups, users can be located anywhere in the file.
327
328     // Read and parse the content of the file. During parsing, startElement, endElement, and dataElement functions are called.
329     $file = fopen($filename, 'r');
330     while ($data = fread($file, 4096)) {
331       if (!xml_parse($parser, $data, feof($file))) {
332         $this->errors->add(sprintf($i18n->get('error.xml'),
333           xml_get_current_line_number($parser),
334           xml_error_string(xml_get_error_code($parser))));
335       }
336     }
337     if ($this->conflicting_entities) {
338       $this->canImport = false;
339       $this->errors->add($i18n->get('error.user_exists'));
340       $this->errors->add(sprintf($i18n->get('error.cannot_import'), $this->conflicting_entities));
341     }
342
343     $this->firstPass = false; // We are done with 1st pass.
344     xml_parser_free($parser);
345     if ($file) fclose($file);
346     if (!$this->canImport) {
347       unlink($filename);
348       return;
349     }
350     if ($this->errors->yes()) return; // Exit if we have errors.
351
352     // Now we can do a second pass, where real work is done.
353     $parser = xml_parser_create();
354     xml_set_object($parser, $this);
355     xml_set_element_handler($parser, 'startElement', false);
356
357     // Read and parse the content of the file. During parsing, startElement, endElement, and dataElement functions are called.
358     $file = fopen($filename, 'r');
359     while ($data = fread($file, 4096)) {
360       if (!xml_parse($parser, $data, feof($file))) {
361         $this->errors->add(sprintf($i18n->get('error.xml'),
362           xml_get_current_line_number($parser),
363           xml_error_string(xml_get_error_code($parser))));
364       }
365     }
366     xml_parser_free($parser);
367     if ($file) fclose($file);
368     unlink($filename);
369   }
370
371   // uncompress - uncompresses the content of the $in file into the $out file.
372   function uncompress($in, $out) {
373     // Do we have the uncompress function?
374     if (!function_exists('bzopen'))
375       return false;
376
377     // Initial checks of file names and permissions.
378     if (!file_exists($in) || !is_readable ($in))
379       return false;
380     if ((!file_exists($out) && !is_writable(dirname($out))) || (file_exists($out) && !is_writable($out)))
381       return false;
382
383     if (!$out_file = fopen($out, 'wb'))
384       return false;
385     if (!$in_file = bzopen ($in, 'r'))
386       return false;
387
388     while (!feof($in_file)) {
389       $buffer = bzread($in_file, 4096);
390       fwrite($out_file, $buffer, 4096);
391     }
392     bzclose($in_file);
393     fclose ($out_file);
394     return true;
395   }
396
397   // createGroup function creates a new group.
398   private function createGroup($fields) {
399     global $user;
400     global $i18n;
401     $mdb2 = getConnection();
402
403     $columns = '(parent_id, org_id, name, currency, decimal_mark, lang, date_format, time_format'.
404       ', week_start, tracking_mode, project_required, task_required, record_type, bcc_email'.
405       ', allow_ip, password_complexity, plugins, lock_spec'.
406       ', workday_minutes, config, created, created_ip, created_by)';
407
408     $values = ' values (';
409     $values .= $mdb2->quote($fields['parent_id']);
410     $values .= ', '.$mdb2->quote($fields['org_id']);
411     $values .= ', '.$mdb2->quote(trim($fields['name']));
412     $values .= ', '.$mdb2->quote(trim($fields['currency']));
413     $values .= ', '.$mdb2->quote($fields['decimal_mark']);
414     $values .= ', '.$mdb2->quote($fields['lang']);
415     $values .= ', '.$mdb2->quote($fields['date_format']);
416     $values .= ', '.$mdb2->quote($fields['time_format']);
417     $values .= ', '.(int)$fields['week_start'];
418     $values .= ', '.(int)$fields['tracking_mode'];
419     $values .= ', '.(int)$fields['project_required'];
420     $values .= ', '.(int)$fields['task_required'];
421     $values .= ', '.(int)$fields['record_type'];
422     $values .= ', '.$mdb2->quote($fields['bcc_email']);
423     $values .= ', '.$mdb2->quote($fields['allow_ip']);
424     $values .= ', '.$mdb2->quote($fields['password_complexity']);
425     $values .= ', '.$mdb2->quote($fields['plugins']);
426     $values .= ', '.$mdb2->quote($fields['lock_spec']);
427     $values .= ', '.(int)$fields['workday_minutes'];
428     $values .= ', '.$mdb2->quote($fields['config']);
429     $values .= ', now(), '.$mdb2->quote($_SERVER['REMOTE_ADDR']).', '.$mdb2->quote($user->id);
430     $values .= ')';
431
432     $sql = 'insert into tt_groups '.$columns.$values;
433     $affected = $mdb2->exec($sql);
434     if (is_a($affected, 'PEAR_Error')) {
435       $this->errors->add($i18n->get('error.db'));
436       return false;
437     }
438
439     $group_id = $mdb2->lastInsertID('tt_groups', 'id');
440     return $group_id;
441   }
442 }