diff --git a/application/classes/controller/templatedefault/affiliate.php b/application/classes/controller/templatedefault/affiliate.php new file mode 100644 index 00000000..ef428dc7 --- /dev/null +++ b/application/classes/controller/templatedefault/affiliate.php @@ -0,0 +1,15 @@ + diff --git a/application/classes/lnapp/config.php b/application/classes/lnapp/config.php index fe399497..7bc3d8fe 100644 --- a/application/classes/lnapp/config.php +++ b/application/classes/lnapp/config.php @@ -22,6 +22,10 @@ abstract class lnApp_Config extends Kohana_Config { if (! $site = CLI::options('site')) throw new Kohana_Exception(_('Cant figure out the site, use --site= for CLI')); + // @todo Need to move this to earlier into the processing stream. + // we can then do away with the test above. + $_SERVER['SERVER_NAME'] = $site['site']; + return $site['site']; } diff --git a/modules/account/classes/auth/osb.php b/modules/account/classes/auth/osb.php index 0459515f..132e8a42 100644 --- a/modules/account/classes/auth/osb.php +++ b/modules/account/classes/auth/osb.php @@ -250,17 +250,8 @@ class Auth_OSB extends Auth_ORM { * * @return boolean TRUE if authorised, FALSE if not. */ - public function authorised($aid) { - if (! $this->get_user()) - return FALSE; - - // @todo Consider caching this. - $ao = ORM::factory('account',$this->get_user()->id); - - if (! $ao->loaded() OR ($aid != $ao->id AND ! $ao->admin())) - return FALSE; - - return TRUE; + public function authorised($aid,$afid=NULL) { + return (($ao = $this->get_user()) AND $ao->loaded() AND ($aid == $ao->id OR $ao->isAdmin() OR (! is_null($afid) AND $afid == $ao->affiliate->id))) ? TRUE : FALSE; } } ?> diff --git a/modules/account/classes/model/account.php b/modules/account/classes/model/account.php index cbdefa1b..4e439e80 100644 --- a/modules/account/classes/model/account.php +++ b/modules/account/classes/model/account.php @@ -18,6 +18,9 @@ class Model_Account extends Model_Auth_UserDefault { 'payment'=>array('far_key'=>'id'), 'service' => array('far_key'=>'id'), ); + protected $_has_one = array( + 'affiliate' => array('far_key'=>'id'), + ); protected $_display_filters = array( 'date_orig'=>array( @@ -69,11 +72,11 @@ class Model_Account extends Model_Auth_UserDefault { return $this->group->find_all(); } - public function admin() { + public function isAdmin() { // @todo Define admins in the config file or DB - $admins = array('Root'); + $admins = array(ORM::factory('group',array('name'=>'Root'))); - return $this->has($admins); + return $this->has('group',$admins); } /** diff --git a/modules/email/classes/controller/admin/email.php b/modules/email/classes/controller/admin/email.php index 6b44a9af..96098f12 100644 --- a/modules/email/classes/controller/admin/email.php +++ b/modules/email/classes/controller/admin/email.php @@ -32,6 +32,7 @@ class Controller_Admin_Email extends Controller_TemplateDefault_Admin { array( 'id'=>array('label'=>'ID','url'=>'user/email/view/'), 'date_orig'=>array('label'=>'Date'), + 'email'=>array('label'=>'To'), 'translate_resolve("subject")'=>array('label'=>'Subject'), 'account->accnum()'=>array('label'=>'Cust ID'), 'account->name()'=>array('label'=>'Customer'), diff --git a/modules/email/classes/email/template.php b/modules/email/classes/email/template.php index 6fe55f73..ed5e3aef 100644 --- a/modules/email/classes/email/template.php +++ b/modules/email/classes/email/template.php @@ -28,17 +28,18 @@ class Email_Template { $language_id = $this->default_lang; $this->etto = $this->template->email_template_translate->where('language_id','=',$language_id)->find(); - if (! $this->etto->loaded() AND - ($this->etto = $this->template->email_template_translate->where('language_id','=',$this->default_lang)->find()) AND ! $this->etto->loaded()) + if (! $this->etto->loaded()) + $this->etto = $this->template->email_template_translate->where('language_id','=',$this->default_lang)->find(); - // @todo Change this to log/email the admin - return; - throw new Kohana_Exception('No template (:template) found for user language (:language_id) or default language (:default_lang)', - array(':template'=>$this->template->name,':language_id'=>$language_id,':default_lang'=>$this->default_lang)); + // @todo Change this to log/email the admin + return; + #throw new Kohana_Exception('No template (:template) found for user language (:language_id) or default language (:default_lang)', + # array(':template'=>$this->template->name,':language_id'=>$language_id,':default_lang'=>$this->default_lang)); } public function __set($key,$value) { switch ($key) { + case 'bcc': case 'to': if (! is_array($value) OR ! array_intersect(array('email','account'),array_keys($value))) throw new Kohana_Exception('Values for to should be an array of either "mail" or "account", however :value was given',array(':value'=>serialize($value))); @@ -61,6 +62,7 @@ class Email_Template { public function __get($key) { switch ($key) { + case 'bcc': case 'to': if (empty($this->email_data[$key])) return array(); @@ -134,6 +136,9 @@ class Email_Template { } } + if (isset($this->email_data['bcc'])) + $sm->setBcc($this->bcc); + if ($admin OR ($admin = Config::testmail($this->template->name))) { $sm->setTo($admin); $sa = array(1); @@ -155,6 +160,7 @@ class Email_Template { $elo->clear(); $elo->account_id = $id; + $elo->email = implode(',',array_keys($this->to)); $elo->email_template_translate_id = $this->etto->id; $elo->data = $data; $elo->save(); diff --git a/modules/email/views/email/user/view.php b/modules/email/views/email/user/view.php index a17da466..40fcabd4 100644 --- a/modules/email/views/email/user/view.php +++ b/modules/email/views/email/user/view.php @@ -1,6 +1,6 @@ - + diff --git a/modules/export/classes/controller/affiliate/export.php b/modules/export/classes/controller/affiliate/export.php new file mode 100644 index 00000000..10bec491 --- /dev/null +++ b/modules/export/classes/controller/affiliate/export.php @@ -0,0 +1,91 @@ +TRUE, + 'export'=>TRUE, + ); + + /** + * Export plugins must define an export action. + */ + public function action_export() { + if (empty($_POST['plugin'])) + $this->request->redirect('affiliate/export'); + + $sc = sprintf('Export_%s',$_POST['plugin']); + if (! class_exists($sc)) + throw new Kohana_Exception('Export Class doesnt exist for :plugin',array(':plugin'=>$_POST['plugin'])); + else + $export = new $sc; + + // @todo: Need to limit this to affiliate acounts + $export->export(); + } + + /** + * This is the main call to export, providing a list of items to export and + * setting up the page to call the export plugin when submitted. + */ + public function action_index($daysago) { + // @todo this should come from a file list + $TBRexportplugins = array('quicken'=>'Export to Quicken'); + + if (! $daysago) + $daysago = 30; + + // @todo: Need to limit this to affiliate acounts + $payments = ORM::factory('payment') + ->export($daysago); + + if (count($payments)) { + $output = Form::open(Request::current()->uri(array('action'=>'export'))); + $output .= '
To:account->name(),$elo->account->display('email')); ?>To:account->name(),$elo->display('email')); ?>
Date:display('date_orig'); ?>
'; + + $output .= View::factory('export/payment/header') + ->set('plugins',$TBRexportplugins); + + $i = 0; + foreach ($payments as $payment) { + $output .= View::factory('export/payment/body') + ->set('payment',$payment) + ->set('i',$i++%2); + } + + $output .= '
'; + $output .= Form::submit('submit','export',array('class'=>'form_button')); + $output .= Form::close(); + + Style::add(array( + 'type'=>'file', + 'data'=>'css/list.css', + )); + + Block::add(array( + 'title'=>_('Payments to Export'), + 'body'=>$output, + )); + + # Nothing to export + } else { + SystemMessage::add(array( + 'title'=>_('No payments to export'), + 'type'=>'info', + 'body'=>sprintf(_('There are no payments within the last %s days (since %s) to show.'), + $daysago,date(Kohana::config('osb')->get('date_format'),$daysago*86400+time())), + )); + } + } +} +?> diff --git a/modules/export/classes/controller/export.php b/modules/export/classes/controller/export.php new file mode 100644 index 00000000..bacc9c68 --- /dev/null +++ b/modules/export/classes/controller/export.php @@ -0,0 +1,15 @@ + diff --git a/modules/invoice/classes/controller/task/invoice.php b/modules/invoice/classes/controller/task/invoice.php index 55779391..41b03e81 100644 --- a/modules/invoice/classes/controller/task/invoice.php +++ b/modules/invoice/classes/controller/task/invoice.php @@ -11,7 +11,9 @@ * @license http://dev.osbill.net/license.html */ class Controller_Task_Invoice extends Controller_Task { - public function action_list($mode) { + public function action_list() { + $mode = $this->request->param('id'); + $io = ORM::factory('invoice'); $tm = 'list_'.$mode; @@ -30,7 +32,7 @@ class Controller_Task_Invoice extends Controller_Task { $duelist .= View::factory('invoice/task/'.$tm.'_foot'); // Send our email - $et = Email_Template::instance('task_invoice_overdue'); + $et = Email_Template::instance('task_list_invoice_overdue'); // @todo Update this to be dynamic $et->to = array('account'=>array(1,68)); @@ -44,5 +46,37 @@ class Controller_Task_Invoice extends Controller_Task { $output = sprintf('List (%s) sent to: %s',$mode,implode(',',array_keys($et->to))); $this->response->body($output); } + + public function action_remind_due() { + // @todo This should go in a config somewhere + $days = 5; + $io = ORM::factory('invoice'); + + foreach ($io->list_due(time()-86400*$days) as $io) { + // If we have already sent a reminder, we'll skip to the next one. + if ($io->remind('due_reminder') AND (is_null($x=$this->request->param('id')) OR $x != 'again')) + continue; + + // Send our email + $et = Email_Template::instance('task_invoice_due_reminder'); + + $et->to = array('account'=>array($io->account_id)); + $et->variables = array( + 'DUE'=>$io->due(TRUE), + 'INV_NUM'=>$io->refnum(), + 'INV_URL'=>URL::site('user/invoice/view/'.$io->id,'http'), + 'DUE_DATE'=>$io->display('due_date'), + 'FIRSTNAME'=>$io->account->first_name, + 'SITE_NAME'=>Config::sitename(), + ); + + // @todo Record email log id if possible. + if ($et->send()) + $io->set_remind('due_reminder',time()); + } + + $output = _('Overdue Reminders Sent.'); + $this->response->body($output); + } } ?> diff --git a/modules/invoice/classes/controller/user/invoice.php b/modules/invoice/classes/controller/user/invoice.php index 47e59538..fb6bef85 100644 --- a/modules/invoice/classes/controller/user/invoice.php +++ b/modules/invoice/classes/controller/user/invoice.php @@ -60,7 +60,7 @@ class Controller_User_Invoice extends Controller_TemplateDefault_User { $io = ORM::factory('invoice',$id); - if (! $io->loaded() OR ! Auth::instance()->authorised($io->account_id)) { + if (! $io->loaded() OR ! Auth::instance()->authorised($io->account_id,$io->affiliate_id)) { $this->template->content = 'Unauthorised or doesnt exist?'; return FALSE; } diff --git a/modules/invoice/classes/model/invoice.php b/modules/invoice/classes/model/invoice.php index 4bdedaca..f38779b3 100644 --- a/modules/invoice/classes/model/invoice.php +++ b/modules/invoice/classes/model/invoice.php @@ -299,8 +299,6 @@ class Model_Invoice extends ORMOSB { } else throw new Kohana_Exception('Couldnt save invoice for some reason?'); - - echo Kohana::debug(array('saved'=>$this)); } public function calc_total(Validate $array, $field) { @@ -325,6 +323,48 @@ class Model_Invoice extends ORMOSB { $this->_changed[$field] = $field; } + /** + * Check the reminder value + */ + public function remind($key) { + if (! $this->loaded()) + return NULL; + + if (! trim($this->reminders)) + return FALSE; + + if (! preg_match('/^a:/',$this->reminders)) + throw new Kohana_Exception('Reminder is not an array? (:reminder)',array(':remind',$this->reminders)); + + $remind = unserialize($this->reminders); + return isset($remind[$key]) ? $remind[$key] : FALSE; + } + + public function set_remind($key,$value,$add=FALSE) { + if (! $this->loaded()) + throw new Kohana_Exception('Cant call :method when a record not loaded.',array(':method',__METHOD__)); + + if (! trim($this->reminders)) { + $remind = array(); + $remind[$key][] = $value; + + } else { + if (! preg_match('/^a:/',$this->reminders)) + throw new Kohana_Exception('Reminder is not an array? (:reminder)',array(':remind',$this->reminders)); + + $remind = unserialize($this->reminders); + + if ($add) + $remind[$key][] = $value; + else + $remind[$key] = $value; + } + + $this->reminders = serialize($remind); + $this->save(); + return $this->saved(); + } + /** LIST FUNCTIONS **/ /** diff --git a/modules/service/classes/controller/affiliate/service.php b/modules/service/classes/controller/affiliate/service.php new file mode 100644 index 00000000..b09892ae --- /dev/null +++ b/modules/service/classes/controller/affiliate/service.php @@ -0,0 +1,303 @@ +'services'); + + protected $secure_actions = array( + 'list'=>TRUE, + 'listbycheckout'=>TRUE, + 'listadslservices'=>TRUE, + 'listhspaservices'=>TRUE, + ); + + /** + * Show a list of services + */ + public function action_list() { + $so = ORM::factory('service'); + + Block::add(array( + 'title'=>_('System Customer Services'), + 'body'=>Table::display( + $so->where('affiliate_id','=',$this->ao->affiliate->id)->find_all(), + 25, + array( + 'id'=>array('label'=>'ID','url'=>'user/service/view/'), + 'type'=>array('label'=>'Type'), + 'name()'=>array('label'=>'Details'), + 'recur_schedule'=>array('label'=>'Billing'), + 'price'=>array('label'=>'Price','class'=>'right'), + 'active'=>array('label'=>'Active'), + 'account->accnum()'=>array('label'=>'Cust ID'), + 'account->name()'=>array('label'=>'Customer'), + ), + array( + 'page'=>TRUE, + 'type'=>'select', + 'form'=>'user/email/view', + )), + )); + } + + /** + * List all services by their default checkout method + */ + public function action_listbycheckout() { + // @todo need to add the DB prefix here + // @todo need to remove the explicit references to the group_id + // @todo need to restrict this to affiliate services + $services = DB::query(Database::SELECT,' +SELECT c.id AS cid,c.name as checkout_plugin_name,s.id AS sid,a.company,a.first_name,a.last_name,a.id as aid +FROM ab_service s LEFT JOIN ab_account_billing ab ON (s.account_billing_id=ab.id) LEFT JOIN ab_checkout c ON (ab.checkout_plugin_id=c.id),ab_account a, ab_account_group ag +WHERE s.active=1 AND s.price > 0 AND s.account_id=a.id AND a.id=ag.account_id AND ((s.account_billing_id IS NOT NULL AND ag.group_id!=2 ) OR (a.id=ag.account_id and ag.group_id=1003)) +ORDER BY c.id,s.recur_schedule,c.name,a.company,a.last_name,a.first_name + ') + ->execute(); + + // @todo If no items, show a nice message. This is not correct for ORM. + if (! count($services)) { + echo Kohana::debug('No services with account_billing'); + die(); + } + + $last_checkout = ''; + $last_account = ''; + $i = 0; + $sc = $st = 0; + $output = ''; + foreach ($services as $service) { + $so = ORM::factory('service',$service['sid']); + $si = $so->account_id.$so->recur_schedule; + + if (($si != $last_account) AND $last_account) { + if ($sc > 1) + $output .= View::factory('service/admin/list/bycheckout_subtotal') + ->set('subtotal',Currency::display($st)) + ->set('i',$i++%2); + $sc = $st = 0; + } + + if (($service['cid'] != $last_checkout) OR (! is_null($last_checkout) AND ! $last_checkout)) { + $output .= View::factory('service/admin/list/bycheckout_header') + ->set('checkout_name',$service['checkout_plugin_name']) + ->set('last_checkout',$last_checkout); + } + + $last_checkout = $service['cid']; + $last_account = $si; + // @todo This rounding should be a system default + $st += round($so->price+$so->tax(),2); + $sc++; + + $output .= View::factory('service/admin/list/bycheckout_body') + ->set('service',$so) + ->set('i',$i++%2); + } + + // Last subtotal + if ($sc > 1) + $output .= View::factory('service/admin/list/bycheckout_subtotal') + ->set('subtotal',$st) + ->set('i',$i++%2); + + $output .= '
'; + + Block::add(array( + 'title'=>_('List all Services by Default Payment Method'), + 'body'=>$output, + )); + + Style::add(array( + 'type'=>'file', + 'data'=>'css/list.css', + )); + } + + //@todo this should really be in a different class, since adsl wont be part of the main app + public function action_listadslservices() { + // @todo need to add the DB prefix here + // @todo need to restrict this to affiliate services + $services = DB::query(Database::SELECT,' +SELECT A.service_id +FROM ab_service__adsl A,ab_service B,ab_account C,ab_service D,ab_product E +WHERE B.active=1 AND A.service_id=B.id AND A.site_id=B.site_id +AND B.account_id=C.id AND B.site_id=C.site_id +AND A.service_id=D.id AND A.site_id=D.site_id +AND D.product_id=E.id AND D.site_id=E.site_id +AND E.sku like "%ADSL%" +ORDER BY C.last_name,B.account_id,A.service_number + ') + ->execute(); + + // @todo If no items, show a nice message. This is not correct for ORM. + if (! count($services)) { + echo Kohana::debug('No services for ADSL'); + die(); + } + + $last_account = ''; + $i = 0; + $output = ''; + foreach ($services as $service) { + $so = ORM::factory('service',$service['service_id']); + + if ($last_account != $so->account_id) { + if ($i) + $output .= ''; + + $output .= View::factory('service/admin/list/adslservices_header') + ->set('service',$so); + + $last_account = $so->account_id; + } + + $output .= View::factory('service/admin/list/adslservices_body') + ->set('service',$so) + ->set('i',$i++%2); + } + $output .= '
 
'; + + // Chart the traffic for the last 12 months. + // @todo need to add the DB prefix here + $traffic = DB::query(Database::SELECT,sprintf(' +SELECT DATE_FORMAT(DATE,"%%y-%%m") AS MONTH,SID,MAX(NUM) AS NUM,SUM(DOWN_PEAK) AS DOWN_PEAK,SUM(IFNULL(DOWN_OFFPEAK,0)+IFNULL(PEER,0)+IFNULL(INTERNAL,0)) AS DOWN_OFFPEAK +FROM ab_view_traffic_adsl_daily +WHERE SID in (%s) AND DATE>"%s" +GROUP BY DATE_FORMAT(DATE,"%%Y-%%m"),SID + ','1,2',date('Y-m',time()-365*86400))) + ->execute(); + + $peak = $offpeak = $services = array(); + + foreach ($traffic as $a => $v) { + $peak[$v['SID']]['Peak'][$v['MONTH']] = $v['DOWN_PEAK']; + $peak[$v['SID']]['OffPeak'][$v['MONTH']] = $v['DOWN_OFFPEAK']; + $peak[$v['SID']]['Services'][$v['MONTH']] = $v['NUM']; + } + + $google = GoogleChart::factory('vertical_bar'); + $google->title = sprintf('ADSL traffic as at %s',date('Y-m-d',strtotime('yesterday'))); + $google->series(array( + 'title'=>array('Exetel-Peak','Exetel-Offpeak'), + 'axis'=>'x', + 'data'=>array('Exetel-Peak'=>$peak[1]['Peak'],'Exetel-OffPeak'=>$peak[1]['OffPeak']))); + $google->series(array( + 'title'=>array('People-Peak','People-Offpeak'), + 'axis'=>'x', + 'data'=>array('People-Peak'=>$peak[2]['Peak'],'People-OffPeak'=>$peak[2]['OffPeak']))); + $google->series(array( + 'title'=>'Exetel-Services', + 'axis'=>'r', + 'data'=>array('Exetel-Services'=>$peak[1]['Services']))); + $google->series(array( + 'title'=>'People-Services', + 'axis'=>'r', + 'data'=>array('People-Services'=>$peak[2]['Services']))); + + Block::add(array( + 'body'=>$google, + )); + + Block::add(array( + 'title'=>_('List all ADSL Services'), + 'body'=>$output, + )); + + Style::add(array( + 'type'=>'file', + 'data'=>'css/list.css', + )); + } + + public function action_listhspaservices() { + // @todo need to add the DB prefix here + // @todo need to restrict this to affiliate services + $services = DB::query(Database::SELECT,' +SELECT A.service_id +FROM ab_service__adsl A,ab_service B,ab_account C,ab_service D,ab_product E +WHERE B.active=1 AND A.service_id=B.id AND A.site_id=B.site_id +AND B.account_id=C.id AND B.site_id=C.site_id +AND A.service_id=D.id AND A.site_id=D.site_id +AND D.product_id=E.id AND D.site_id=E.site_id +AND E.sku like "%HSPA%" +ORDER BY C.last_name,B.account_id,A.service_number + ') + ->execute(); + + // @todo If no items, show a nice message. This is not correct for ORM. + if (! count($services)) { + echo Kohana::debug('No services for HSPA'); + die(); + } + + $last_account = ''; + $i = 0; + $output = ''; + foreach ($services as $service) { + $so = ORM::factory('service',$service['service_id']); + + if ($last_account != $so->account_id) { + if ($i) + $output .= ''; + + $output .= View::factory('service/admin/list/adslservices_header') + ->set('service',$so); + + $last_account = $so->account_id; + } + + $output .= View::factory('service/admin/list/adslservices_body') + ->set('service',$so) + ->set('i',$i++%2); + } + $output .= '
 
'; + + // Chart the traffic for the last 12 months. + // @todo need to add the DB prefix here + $traffic = DB::query(Database::SELECT,sprintf(' +SELECT DATE_FORMAT(DATE,"%%y-%%m") AS MONTH,SID,MAX(NUM) AS NUM,SUM(DOWN_PEAK)*1000 AS DOWN_PEAK,SUM(IFNULL(DOWN_OFFPEAK,0)+IFNULL(PEER,0)+IFNULL(INTERNAL,0))*1000 AS DOWN_OFFPEAK +FROM ab_view_traffic_adsl_daily +WHERE SID=%s AND DATE>"%s" +GROUP BY DATE_FORMAT(DATE,"%%Y-%%m"),SID + ',3,date('Y-m',time()-365*86400))) + ->execute(); + + $peak = $offpeak = $services = array(); + + foreach ($traffic as $a => $v) { + $peak['Peak'][$v['MONTH']] = $v['DOWN_PEAK']; + $peak['OffPeak'][$v['MONTH']] = $v['DOWN_OFFPEAK']; + $peak['Services'][$v['MONTH']] = $v['NUM']; + } + + $google = GoogleChart::factory('vertical_bar'); + $google->title = sprintf('HSPA traffic as at %s',date('Y-m-d',strtotime('yesterday'))); + $google->series(array('title'=>array('Peak','Offpeak'),'axis'=>'x','data'=>array('Peak'=>$peak['Peak'],'OffPeak'=>$peak['OffPeak']))); + $google->series(array('title'=>'Services','axis'=>'r','data'=>array('Services'=>$peak['Services']))); + + Block::add(array( + 'body'=>$google, + )); + + Block::add(array( + 'title'=>_('List all HSPA Services'), + 'body'=>$output, + )); + + Style::add(array( + 'type'=>'file', + 'data'=>'css/list.css', + )); + } +} +?> diff --git a/modules/service/classes/controller/user/service.php b/modules/service/classes/controller/user/service.php index b6026f53..ab147718 100644 --- a/modules/service/classes/controller/user/service.php +++ b/modules/service/classes/controller/user/service.php @@ -56,7 +56,7 @@ class Controller_User_Service extends Controller_TemplateDefault_User { $so = ORM::factory('service',$id); - if (! $so->loaded() OR ! Auth::instance()->authorised($so->account_id)) { + if (! $so->loaded() OR ! Auth::instance()->authorised($so->account_id,$so->affiliate_id)) { $this->template->content = 'Unauthorised or doesnt exist?'; return FALSE; } diff --git a/modules/task/classes/controller/task/task.php b/modules/task/classes/controller/task/task.php index dc6da59c..bcda0ba7 100644 --- a/modules/task/classes/controller/task/task.php +++ b/modules/task/classes/controller/task/task.php @@ -20,7 +20,7 @@ class Controller_Task_Task extends Controller_Task { $tm = 'list_'.$this->request->param('id'); if (! method_exists($to,$tm)) - throw new Kohana_Exception('Unknown Task List command :command',array(':command'=>$mode)); + throw new Kohana_Exception('Unknown Task List command :command',array(':command'=>$tm)); $output .= sprintf('%2s %30s %21s %21s %40s', 'ID','Command','Last Run','Next Run','Description');