понедельник, 14 декабря 2009 г.

Symfony: settings

Нередко требуется часть настроек из app.yml сделать редактируемыми в backend, напишем простенький класс.
Схема:
Config:
tableName: sf_config
columns:
id: { type: integer(4), primary: true, autoincrement: true }
name: { type: string(32) }
value: { type: string(32) }
description: { type: string(1024) }


Класс /lib/myConfig.class.php:
class myConfig extends sfConfig
{
public static function get($name, $default = null, $description = null)
{
if (!sfConfig::get($name))
{
$res = Doctrine::getTable('Config')->findOneByName($name);
if (!$res)
{
$c = new Config();
$c->setName($name);
$c->setValue($default);
$c->setDescription($description);
$c->save();
}
}

return (isSet(self::$config[$name]) ? self::$config[$name] : $default);
}

}


Данный класс не находя в настройках требуемого параметра, проверяет его наличие в таблице, и в случаее отсутствия добавляет его.

Фильтр /lib/filters/myConfigFilter.class.php:
class myConfigFilter extends sfFilter
{
public function execute ($filterChain)
{
$configs = Doctrine::getTable('Config')->findAll();
foreach ($configs as $config)
{
sfConfig::set($config->getName(), $config->getValue());
}
$filterChain->execute();
}
}


Фильтр экспортирует из БД настройки в sfConfig.

Добавляем в fontend/config/filters.yml

myConfig:
class: myConfigFilter


Используем:
$postDirName = sfConfig::get('sf_upload_dir').'/'.myConfig::get('posts_dir', 'posts');
'max_size' => myConfig::get('post_max_icon_size', '10', 'size in KBytes')*1024, //(10*1024=10KB in bytes)


Сгенерируем управление из backend
$ symfony doctrine:generate-admin backend Config


Используем backend_dev.php/config

понедельник, 26 октября 2009 г.

Symfony: Doctrine slugify кириллица

/lib/Slugify.class.php

class SlugifyClass {
  static function Slugify($title) {
    $gost = array(
     "Є"=>"EH","І"=>"I","і"=>"i","№"=>"#","є"=>"eh",
     "А"=>"A","Б"=>"B","В"=>"V","Г"=>"G","Д"=>"D",
     "Е"=>"E","Ё"=>"JO","Ж"=>"ZH",
     "З"=>"Z","И"=>"I","Й"=>"JJ","К"=>"K","Л"=>"L",
     "М"=>"M","Н"=>"N","О"=>"O","П"=>"P","Р"=>"R",
     "С"=>"S","Т"=>"T","У"=>"U","Ф"=>"F","Х"=>"KH",
     "Ц"=>"C","Ч"=>"CH","Ш"=>"SH","Щ"=>"SHH","Ъ"=>"'",
     "Ы"=>"Y","Ь"=>"","Э"=>"EH","Ю"=>"YU","Я"=>"YA",
     "а"=>"a","б"=>"b","в"=>"v","г"=>"g","д"=>"d",
     "е"=>"e","ё"=>"jo","ж"=>"zh",
     "з"=>"z","и"=>"i","й"=>"jj","к"=>"k","л"=>"l",
     "м"=>"m","н"=>"n","о"=>"o","п"=>"p","р"=>"r",
     "с"=>"s","т"=>"t","у"=>"u","ф"=>"f","х"=>"kh",
     "ц"=>"c","ч"=>"ch","ш"=>"sh","щ"=>"shh","ъ"=>"",
     "ы"=>"y","ь"=>"","э"=>"eh","ю"=>"yu","я"=>"ya","«"=>"","»"=>"","—"=>"-"," "=>"-"
    );

    $iso = array(
     "Є"=>"YE","І"=>"I","Ѓ"=>"G","і"=>"i","№"=>"#","є"=>"ye","ѓ"=>"g",
     "А"=>"A","Б"=>"B","В"=>"V","Г"=>"G","Д"=>"D",
     "Е"=>"E","Ё"=>"YO","Ж"=>"ZH",
     "З"=>"Z","И"=>"I","Й"=>"J","К"=>"K","Л"=>"L",
     "М"=>"M","Н"=>"N","О"=>"O","П"=>"P","Р"=>"R",
     "С"=>"S","Т"=>"T","У"=>"U","Ф"=>"F","Х"=>"X",
     "Ц"=>"C","Ч"=>"CH","Ш"=>"SH","Щ"=>"SHH","Ъ"=>"'",
     "Ы"=>"Y","Ь"=>"","Э"=>"E","Ю"=>"YU","Я"=>"YA",
     "а"=>"a","б"=>"b","в"=>"v","г"=>"g","д"=>"d",
     "е"=>"e","ё"=>"yo","ж"=>"zh",
     "з"=>"z","и"=>"i","й"=>"j","к"=>"k","л"=>"l",
     "м"=>"m","н"=>"n","о"=>"o","п"=>"p","р"=>"r",
     "с"=>"s","т"=>"t","у"=>"u","ф"=>"f","х"=>"x",
     "ц"=>"c","ч"=>"ch","ш"=>"sh","щ"=>"shh","ъ"=>"",
     "ы"=>"y","ь"=>"","э"=>"e","ю"=>"yu","я"=>"ya","«"=>"","»"=>"","—"=>"-"," "=>"-"
    );

    $rtl_standard = sfConfig::get('rtl_standard', 'gost') ;

    //to lower case

    $title = mb_strtolower($title, 'UTF-8');
    
    switch ($rtl_standard) {
      case 'off':
          return $title;
      case 'gost':
          $out = trim(strtr($title, $gost), '-');
      default:
          $out = trim(strtr($title, $iso), '-');
    }
    
    // clean slug
    $out = preg_replace('/[^a-z0-9\-]+/i', '', $out);
    return $out;
  }
}



Использование

Post:
  actAs:
    Timestampable: ~
    Taggable: ~
    Sluggable:
      unique: true
      fields: [title]
      canUpdate: true
      builder: [SlugifyClass, Slugify]

вторник, 29 сентября 2009 г.

Symfony: Doctrine order by calculated field

Положим нам нужно вывести 10 наиболее комментируемых постов.
Кусок схемы:
Post:
tableName: blog_post
actAs:
Timestampable: ~
Taggable: ~
columns:
id: { type: integer(4), primary: true, autoincrement: true }
title: { type: string(255) }
extract: { type: string(1024) }
content: { type: string(4096) }
is_published: { type: boolean, default: false }

Comment:
tableName: blog_comment
actAs:
Timestampable: ~
columns:
id: { type: integer(4), primary: true, autoincrement: true }
post_id: { type: integer(4), notnull: true }
name: { type: string(100) }
email: { type: string(100) }
content: { type: string(4096) }
subscribe: { type: boolean, default: false }
relations:
Post:
class: Post
local: post_id
foreign: id
foreignAlias: PostComments
type: one
foreignType: many
onDelete: CASCADE


Получим популярные посты одним запросом:
  public function executeGetMostCommentedBlogPosts()
{
$q = Doctrine_Query::create()
->select('p.*')
->addSelect('(SELECT count(*) FROM PostComments pc WHERE pc.post_id = p.id) as comments_count')
->from('Post p')
->where('p.is_published', true)
->orderBy('comments_count DESC')
->addOrderBy('d.created_at DESC')
->limit(10);

$this->posts = $q->execute();
}

понедельник, 24 августа 2009 г.

Symfony: date selector (выбор даты рождения)

Нас не устраивает стандартный выбор даты в виде 3х листбоксов в формате месяц/день/год, да еще и месяцы указаны цифрами, что вызывает массу затруднений.
Мы хотим такой вид:


Решение:
  public function setup()
{
$years = range(1950, 2000); //Creates array of years between 1950-2000
$years_list = array_combine($years, $years); //Creates new array where key and value are both values from $years list

$this->setWidgets(array(
'id' => new sfWidgetFormInputHidden(),
'city' => new sfWidgetFormInput(),
'birthday' => new sfWidgetFormI18nDate(
array(
'culture' => 'ru',
'format' => '%day%.%month%.%year%',
'years' => $years_list,
),
array()
),

Symfony: sfWidgetFormInputFileEditable IE broken image

Положим, что мы хотим позволить пользователю прикреплять картинку к своему профилю.
Обратившись к JOBEET находим решение - виджет sfWidgetFormInputFileEditable
Используем его в своей форме:
    
$this->setWidgets(array(
'id' => new sfWidgetFormInputHidden(),
...
'avatar' => new sfWidgetFormInputFileEditable(array(
'label' => 'Ваш аватар',
'file_src' => '/uploads/avatars/'.$this->getObject()->getAvatar(),
'is_image' => true,
'edit_mode' => !$this->isNew(),
)),

Однако вскоре мы обнаруживаем, что когда пользователь не создает. а редактирует свой профиль, и при этом аватар у него не загружен, в форме появляется битый рисунок и предложение удалить несуществующую картинку:


Решение проблемы:
   'avatar'       => new sfWidgetFormInputFileEditable(array(
'label' => 'Avatar for forum, blog, comments, etc...',
'file_src' => '/uploads/avatars/'.$this->getObject()->getAvatar(),
'is_image' => true,
'edit_mode' => ($this->getObject()->getAvatar() OR $this->isNew() ? true: false),
)),

Ну и заинтересовавшимся для комплекта валидатор:
    $this->validatorSchema['avatar'] = new sfValidatorFile(array(
'required' => false,
'path' => sfConfig::get('sf_upload_dir').'/avatars',
'mime_types' => 'web_images',
'max_size' => '40960', //(40KB in bytes)
));

понедельник, 18 мая 2009 г.

Symfony: applications as subdomains

Мы хотим иметь такую конструкцию:
site.com             = frontend.php
www.site.com = frontend.php
dev.www.site.com = frontend_dev.php
backend.site.com = backend.php
dev.backend.site.com = backend_dev.php


Изменяем index.php

require_once(dirname(__FILE__).'/../config/ProjectConfiguration.class.php');

// get the domain parts as an array
@list($tld, $domain, $subdomain, $subdomain2) = array_reverse(explode('.', $_SERVER['HTTP_HOST']));

// determine which subdomain we're looking at
$app = ($subdomain == 'webadmin') ? 'backend' : $subdomain;
$app = (empty($app) || $app == 'www' ) ? 'frontend' : $app;
$env = (empty($subdomain2) || $subdomain2 == 'www') ? 'prod' : $subdomain2;

// determine which app to load based on subdomain
if (!is_dir(realpath(dirname(__FILE__).'/..').'/apps/'.$app))
{
$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'prod', false);
}
else
{
$configuration = ProjectConfiguration::getApplicationConfiguration($app, $env, false);
}

sfContext::createInstance($configuration)->dispatch();


Использованные материалы: Dynamically Loading Symfony Applications Via Subdomains

четверг, 23 апреля 2009 г.

Symfony: raw query, get years list for active posts

public function executeIndex(sfWebRequest $request)
{
//get years
$connection = Propel::getConnection();
$query = '
SELECT YEAR( %s ) AS myYear
FROM %s
WHERE %s = 1
GROUP BY YEAR( %s )
ORDER BY myYear DESC
';

$query = sprintf($query,
PostPeer::PUBLISHED_AT,
PostPeer::TABLE_NAME,
PostPeer::IS_PUBLISHED,
PostPeer::PUBLISHED_AT
);
$statement = $connection->prepare($query);
$statement->execute();
$years = array();
while($resultset = $statement->fetch(PDO::FETCH_OBJ))
{
$years[] = $resultset->myYear;
}
$this->years = $years;
}


returns array(2009, 2008, 2007...)

понедельник, 13 апреля 2009 г.

Symfony: filter by 2 fields in backend

Простая ситуация: в таблице 2 поля
  sf_post:
_attributes: { phpName: Post }
id: ~
title: { type: varchar(255) }
content: { type: longvarchar }
...

Нам не важно в заголовке или в описании есть искомое поле, мы хотим в админке сделать фильтрацию по обоим полям. Берем исходник из кэша и модифицируем /backend/modules/post/actions/actions.class.php
  protected function buildCriteria()
{
$filters = $this->getFilters();
if (is_null($this->filters))
{
$this->filters = $this->configuration->getFilterForm($this->getFilters());
}

$criteria = $this->filters->buildCriteria($this->getFilters());

$this->addSortCriteria($criteria);

$event = $this->dispatcher->filter(new sfEvent($this, 'admin.build_criteria'), $criteria);
$criteria = $event->getReturnValue();

if (!empty($filters['title']['text']))
{
//добавим так же фильтр по полю CONTENT
$c1 = $criteria->getNewCriterion(PostPeer::TITLE, '%'.$filters['title']['text'].'%', Criteria::LIKE);
$c2 = $criteria->getNewCriterion(PostPeer::CONTENT, '%'.$filters['title']['text'].'%', Criteria::LIKE);
$c1->addOr($c2);
$criteria->add($c1);
}

return $criteria;
}

Symfony: add jQuery to helper

Положим что наш самодельный helper должен использовать jQuery, загружен jQuery или нет, мы не знаем, поэтому самое простое в начале скрипта сделать
sfContext::getInstance()->getResponse()->addJavascript('jq/jquery-1.3.2.min.js');

Но это может привести к многократной загрузке ядра jQuery браузером пользователя.
Что бы избежать этого используем простую проверку.
  $jQueryPath = 'jq/jquery-1.3.2.min.js';
$jsLoaded = sfContext::getInstance()->getResponse()->getJavascripts();
if (!array_key_exists($jQueryPath, $jsLoaded))
{
sfContext::getInstance()->getResponse()->addJavascript($jQueryPath);
}

среда, 8 апреля 2009 г.

PHP substr + utf-8

Положим нам надо в списке показывать кусок сообщения длиной не более 100символов, если сообщение больше 100символов, в конце добавить троеточие.
В кодировке UTF простое обрезание строки substr($str, 0, 100), приводит к появлению нераспознанных символов. Нам помогут функции mb_
public function getSubContent()
{
$str = strip_tags($this->getContent());
return mb_substr($str, 0, 100, 'utf-8').
(mb_strlen($str, 'utf-8') > 100 ? '...' : '');
}

Symfony 1.2 tabular(embedded) subforms

Довольно часто при редактировании требуется вывести N дочерних записей в виде таблицы.
К сожалению стандартными средствами Symfony этого сделать не получилось, в первую очередь из за того, что Symfony в каждой строке оставляет место для сообщений об ошибках валидации.
Постараемся с минимумом усилий сделать таблицу с подчиненными записями.
Для примера возьмем очень простую ситуацию - в нашем интернет-магазине каждый товар может иметь 0-N рекомендованных товаров.

Кусок схемы:

propel:
pc_product:
_attributes: { phpName: Product }
id: ~
name: varchar(255)
price1: float
price2: float
description: longvarchar
pic: varchar(255)
is_published: boolean
category_id: { type: integer, foreignReference: id, foreignTable: pc_category, onDelete: cascade, onUpdate: cascade, required: true }

pc_product_recommended:
_attributes: { phpName: ProductRecommended }
id: ~
product_id: { type: integer, foreignReference: id, foreignTable: pc_product, onDelete: cascade, onUpdate: cascade, required: true }
recommend_product_id: { type: integer, foreignReference: id, foreignTable: pc_product, onDelete: cascade, onUpdate: cascade, required: true }
note: { type: varchar(512), default: null }
pos: integer

pc_category:
_attributes: { phpName: Category, treeMode: NestedSet }
id: ~
name: varchar(255)
alias: varchar(1024)
lft: { type: integer, required: false, default: 0, nestedSetLeftKey: 'true'}
rgt: { type: integer, required: false, default: 0, nestedSetRightKey: 'true'}
scope: { type: integer, required: false, default: 0, treeScopeKey: 'true'}
parent: { type: integer }
is_published: { type: boolean }
deleted_at: { type: date }


Изменяем /lib/forms/ProductForm.class.php

class ProductForm extends BaseProductForm
{
public function configure()
{
$this->setWidgets(array(
'category_id' => new sfWidgetFormPropelChoice(array('model' => 'Category', 'add_empty' => false)),
'id' => new sfWidgetFormInputHidden(),
'name' => new sfWidgetFormInput(),
'price1' => new sfWidgetFormInput(),
'price2' => new sfWidgetFormInput(),
'pic' => new myWidgetFormInputFileEditable(),
'is_published' => new sfWidgetFormInputCheckbox(),
));


$this->setValidators(array(
'id' => new sfValidatorPropelChoice(array('model' => 'Product', 'column' => 'id', 'required' => false)),
'name' => new sfValidatorString(array('max_length' => 255, 'required' => false)),
'price1' => new sfValidatorNumber(array('required' => false)),
'price2' => new sfValidatorNumber(array('required' => false)),
'is_published' => new sfValidatorBoolean(array('required' => false)),
'category_id' => new sfValidatorPropelChoice(array('model' => 'Category', 'column' => 'id')),
'pic' => new sfValidatorFile(array(
'required' => false,
'max_size' => '102400', // bytes (1MB)
'mime_types' => array('image/png', 'image/jpeg', 'image/gif',)
)),
));

$this->widgetSchema->setNameFormat('product[%s]');

$this->errorSchema = new sfValidatorErrorSchema($this->validatorSchema);

$this->validatorSchema->setOption('allow_extra_fields', true);
$this->validatorSchema->setOption('filter_extra_fields', false);

//получим дочерние записи
$c = new Criteria();
$c->add(ProductRecommendedPeer::PRODUCT_ID, $this->getObject()->getId());
$c->addAscendingOrderByColumn(ProductRecommendedPeer::ID);
$rps = ProductRecommendedPeer::doSelect($c);
//цикл по дочерним записям
foreach ($rps as $index => $rProduct)
{
$rProductForm = new ProductRecommendedForm($rProduct);
$rProductForm->setWidget('product_id', new sfWidgetFormInputHidden());
$rProductForm->setWidget('del_me', new sfWidgetFormInputCheckbox());
$rProductForm->setValidator('del_me', new sfValidatorBoolean(array('required' => false)));
$rProductForm->widgetSchema->moveField('del_me', sfWidgetFormSchema::FIRST);

$this->embedForm('rProduct-'.$index, $rProductForm);
$this->widgetSchema->setLabel('rProduct-'.$index, $index+1);
}
$rProductForm = new ProductRecommendedForm();

$rProductForm->setWidget('product_id', new sfWidgetFormInputHidden());
//добавляем дополнительный чекбокс, для удаления записи
$rProductForm->setWidget('del_me', new sfWidgetFormInputCheckbox(
array(),
array('disabled' => true)
));
if (!$this->getObject()->isNew())
{
//добавим пустой элемент в списке, на случай если мы не хотим добавлять новую запись
$rProductForm->setWidget('recommend_product_id', new sfWidgetFormPropelChoice(
array('model' => 'Product', 'add_empty' => true)));

$rProductForm->setDefaults(array('product_id' => $this->getObject()->getId()));
//отключим обязательный выбор значения листбокса
$rProductForm->setValidator('recommend_product_id', new sfValidatorInteger(array('required' => false)));
//переносим чекбокс для удаления записи наверх
$rProductForm->widgetSchema->moveField('del_me', sfWidgetFormSchema::FIRST);

$this->embedForm('new-rec', $rProductForm);
$this->widgetSchema->setLabel('new-rec', 'new record');
}


}

public function updateObject($values = null)
{
$object = parent::updateObject();
$path = sfConfig::get('sf_root_dir').DIRECTORY_SEPARATOR.
sfConfig::get('sf_web_dir_name').DIRECTORY_SEPARATOR.sfConfig::get('sf_upload_dir');
$object->setPic(str_replace($path.'/', '', $object->getPic()));
return $object;
}
/*
* do save
*/
protected function doSave($con = null){
$values = $this->getValues();

$path = sfConfig::get('sf_root_dir').'/'.
sfConfig::get('sf_web_dir_name').'/'.sfConfig::get('sf_upload_dir');

if (isset($values['pic_delete'])){
$currentFile = $path.'/'.$this->getObject()->getPic();
if (is_file($currentFile)){
unlink($currentFile);
}
$this->getObject()->setPic('');
}

$file = $values['pic'];

if(!empty($file)){
if (file_exists(sfConfig::get('sf_upload_dir').'/'.$this->getObject()->getPic())){
@unlink($this->getObject()->getPic());
}
$filename = sha1($file->getOriginalName()).$file->getExtension($file->getOriginalExtension());
$path = sfConfig::get('sf_root_dir').DIRECTORY_SEPARATOR.sfConfig::get('sf_web_dir_name').DIRECTORY_SEPARATOR.sfConfig::get('sf_upload_dir');
$file->save($path.'/'.$filename);
}

return parent::doSave($con);
}

}



Если в /generator.yml настройки форм по умолчанию:

form: ~
edit: ~
new: ~

мы видим внизу формы редактирования доп.форму с добавлением записи

она даже работает ;) но внешний вид нас не устраивает, и по работе тоже есть нарекания, при каждом обновлении родительской формы без указания нового рекомендованного товара, у нас добавляется пустая запись, а так же еще не работает удаление записей.

Давайте исправим внешний вид и логику добавления-удаления.

Редактируем в config/generator.yml настройки формы указываем поля для отображения:

form:
display: [ name, price1, price2, pic, is_published ]

Такая небольшая настройка приводит к тому, что исчезают наши встроенные формы, подробнее об этом явлении как нибудь позже...
При этом форма перестает работать, что неудивительно, т.к. мы к форме прикрепили массу валидаторов для подчиненных форм.
Теперь мы в ручную отобразим подформы в табличном виде.
Заходим в кэш и копируем
/cache/backend/.../autoProduct/templates/_form.php в
apps/backend/modules/product/templates/_form.php

Далее заменяем содежимое этого файла на:

<div class="sf_admin_form">
<?php echo form_tag_for($form, '@product') ?>
<?php echo $form->renderHiddenFields() ?>

<?php if ($form->hasGlobalErrors()): ?>
<?php echo $form->renderGlobalErrors() ?>
<?php endif; ?>

<?php foreach ($configuration->getFormFields($form, $form->isNew() ? 'new' : 'edit') as $fieldset => $fields): ?>
<?php include_partial('product/form_fieldset', array('product' => $product, 'form' => $form, 'fields' => $fields, 'fieldset' => $fieldset)) ?>
<?php endforeach; ?>

<?php if ($form->getEmbeddedForms()): ?>
<fieldset id="sf_fieldset_recommended_products">
<h2>Recommended products</h2>
<table>
<tr>
<th>#</th>
<th>del</th>
<th>product</th>
<th>note</th>
<th>pos</th>
</tr>
<?php foreach ($form->getEmbeddedForms() as $index => $embForm): ?>
<tr>
<?php echo $form[$index]['id']->render() ?>
<?php echo $form[$index]['product_id']->render() ?>
<td><?php echo $form[$index]->renderLabel() ?></td>
<td><?php echo $form[$index]['del_me']->renderError() ?><?php echo $form[$index]['del_me']->render() ?></td>
<td><?php echo $form[$index]['recommend_product_id']->renderError() ?><?php echo $form[$index]['recommend_product_id']->render() ?></td>
<td><?php echo $form[$index]['note']->renderError() ?><?php echo $form[$index]['note']->render() ?></td>
<td><?php echo $form[$index]['pos']->renderError() ?><?php echo $form[$index]['pos']->render() ?></td>
</tr>
<?php endforeach; ?>
</table>
</fieldset>
<?php endif; ?>

<?php include_partial('product/form_actions', array('product' => $product, 'form' => $form, 'configuration' => $configuration, 'helper' => $helper)) ?>
</form>
</div>



Подформа преобрела более приятный вид, остается разобраться с обновлением и удалением.
Добавляем новый метод в /lib/forms/ProductForm.class.php

public function updateObjectEmbeddedForms($values, $forms = null)
{
if (is_null($forms))
{
$forms = $this->embeddedForms;
}

foreach ($forms as $name => $form)
{
if (!is_array($values[$name]))
{
continue;
}

if ($form instanceof sfFormPropel)
{
if (isSet($values[$name]['del_me'])){
if ($values[$name]['del_me'] == 1)
{
$form->getObject()->delete();
unset($this->embeddedForms[$name]);
continue;
}
} else {
if (strstr($name, 'new-'))
{
//именно тут мы сами выступаем в роли валидатора, и если одно из обязательных полей пустое,
//мы игнорируем новую запись
$rProduct = $values[$name]['recommend_product_id'];
$rPos = $values[$name]['pos'];
if ( empty($rProduct) OR empty($rPos))
{
unset($this->embeddedForms[$name]);
continue;
}
}
}//if !isset del me
$form->updateObject($values[$name]);
}
else
{
$this->updateObjectEmbeddedForms($values[$name], $form->getEmbeddedForms());
}
}
//die();
}//updateObjectEmbeddedForms