April 8, 2009

Don’t want .html file extention for my link!

To make the symfony1.1 base application compatible with symfony1.2 is not very difficult. Because symfony1.2 considers backward compatibility. If you look inside of symfony, you will find many strange implements, such as link_to1, link_to2. In this case, link_to1 is for symfony1.1 base application, and link_to2 is for symfony1.2.

It made us easy to move on to symfony1.2. And I also upgrade the application I developed with symfony1.1 to symfony1.2. One of the new symfony1.2 feature is routing. Yeah! When I did Jobeet tutorial, it took a bit time to understand the new routing framework. I understand it after I implemented askeet with symfony1.2. :) When I developed askeet with symfony1.2, I used pure symfony1.2, so the generated codes are symfony1.2 base, and did not have any problems. However, an application I'm currently working on was developed with symfony1.1 first. It works with symfony1.2 now , but routing ruling is still syfmony1.1 base, and there are other symfony1.1 base code as well. :(

One of the routing features which added in symfony1.2 is sfPropelRouteCollection. Before the emergence of the useful class, I had to write many CRUD and more rule in routing.yml, but sfPropelRouteCollection helped me a lot to implement model base module. I was trying my symfony1.1 base application to upgrading routing rule for symfony1.2. After adding sfPropelRouteCollection in routing.yml, it seemed it is working great, and result of app:routes command is just what I was expected. :) However, urls displayed converted with link_to or url_for are not what I wanted. :( These helper functions add ".html" for urls. I don't want them! :( I struggled to removing this for a bit. Well, It was easy, but it took me two hours to find this fix. When I found the solution, I was so happy that I could not write post related with my problems.

The problem is generate_shortest_url and extra_parameters_as_query_string values in factories.yml. Well, if you used symfony1.2 to generate application using generate:app, you do not have this problem, because symfony1.2 generate factories.yml with this values. However my application was generated with symfony1.1 almost half year ago, and generate_shortest_url and extra_parameters_as_query_string was not in factories.yml.

As you find in symfony1.2 base application's factories.yml, you need following values in your symfony1.1 base factories.yml.

  1. all:
  2.   routing:
  3.     class: sfPatternRouting
  4.     param:
  5.       generate_shortest_url: true
  6.       extra_parameters_as_query_strings: true

O.K, that's it! :) It is very simple, if you know the reason.

April 5, 2009

Playing with sfForm with Askeet Part 2

It's another post related with sfForm. After using symfony1.2 several months, I found mastering sfForm is crucial for symfony developers. :) I'm still learning it, too. :)

In this post, I will explain QuesionForm, QuestionTagForm. Maybe I should also explain BackendQuestionForm, because QuestionForm uses tag form with auto complete, and BackendQuestionForm does not, and you might want to choose whether you use it or not. Well, I do! ;)

O.K, let's clarify the specification of posting question and tagging. In askeet, you can only post a question from question posting page. Yeah, as it is. Look at the image below. This is the question posting form.

However, tagging is different. You can set tags when you post a question as an author of the question. Plus, if you are authenticated user, you can set/add tag for the questions no matter who post the question. So, there are two ways for tagging. The one you see above, which is tagging with posting question, and the form you found in sidebar when you logined askeet and go to the question detail page as a below image:

To explain how to do it, I use following codes:

  • lib/form/QuestionForm.class.php
  • lib/form/QuestionTagForm.class.php
  • lib/form/BackendQuestionForm.class.php
  • lib/form/sfWidgetFormProtoculousAutocompleter.class.php
  • apps/frontend/modules/question/actions/actions.class.php(executeNew, executeCreate)
  • apps/frontend//modules/question/templates/newSuccess.php
  • apps/frontend/modules/tag/actions/actions.class.php(executeCreate, executeAutocomplete)
  • apps/frontend/modules/tag/components/components.class.php
  • apps/frontend/modules/tag/templates/_add.php
  • apps/frontend/modules/tag/templates/autocompleteSuccess.php

O.K, let's start with the posting question form.

lib/form/QuestionForm.class.php

  1. class QuestionForm extends BaseQuestionForm
  2. {
  3.   protected $questionTagForm = null;
  4.   public function configure()
  5.   {
  6.     unset(
  7.       $this['created_at'],
  8.       $this['updated_at'],
  9.       $this['interested_users'],
  10.       $this['reports'],
  11.       $this['user_id'],
  12.       $this['html_body'],
  13.       $this['stripped_title'],
  14.       $this['report_question_list'],
  15.       $this['question_tag_list'],
  16.       $this['interest_list']
  17.     );
  18.  
  19.     $this->widgetSchema['title'] = new sfWidgetFormInput( array('label' => 'question:')
  20.     );
  21.     $this->validatorSchema['title'] = new sfValidatorString(
  22.       array(),
  23.       array('required' => 'You must give a title to your question')
  24.     );
  25.  
  26.     $this->widgetSchema['body'] = new sfWidgetFormTextarea(
  27.       array('label' => 'describe it:'),
  28.       array('cols' => 40, 'rows' => 10)
  29.     );
  30.     $this->validatorSchema['body'] = new sfValidatorString(
  31.       array('min_length' => 10),
  32.       array(
  33.         'required' => 'You must provide a brief context for your question',
  34.         'min_length' => 'Please, give some more details'
  35.       )
  36.     );
  37.  
  38.     $this->setQuestionTagForm();
  39.     $this->mergeForm($this->questionTagForm);
  40.   }
  41.  
  42.   public function updateObject($values = null)
  43.   {
  44.     $object = parent::updateObject($values);
  45.     if (is_null($object->getUserId())) {
  46.       $userId = sfContext::getInstance()->getUser()->getSubscriberId();
  47.       $object->setUserId($userId);
  48.     }
  49.     $interest = new Interest();
  50.     $interest->setUserId($userId);
  51.     $object->addInterest($interest);
  52.  
  53.     return $object;
  54.   }
  55.  
  56.   public function doSave($con = null)
  57.   {
  58.     parent::doSave($con);
  59.  
  60.     $this->taintedValues['question_id'] = $this->object->getId();
  61.     $this->questionTagForm->bind($this->taintedValues);
  62.     $this->questionTagForm->doSave($con);
  63.   }
  64.  
  65.   protected function setQuestionTagForm()
  66.   {
  67.     $this->questionTagForm = new QuestionTagForm(
  68.       null,
  69.       array('url' => sfContext::getInstance()->getController()->genUrl('tag_autocomplete'))
  70.     );
  71.   }
  72. }

Check out doSave method. It is a bit tricky, because we need to save posted tags as well. When tagging, you are not going to set only one tag. You might want to tag more than one phrases. Also, using mergeForm does not call doSave for the merged form, so you have to override doSave method here. I need to bind the question_id for QuestionTagForm

In the end of configure method, I called setQuestionTagForm and mergeForm, because I will override setQuestionTagForm in BackendQuestionForm, not to use auto completing feature. See the BackendQuestionForm now.

lib/form/BackendQuestionForm.class.php

  1. class BackendQuestionForm extends QuestionForm
  2. {
  3.   public function configure()
  4.   {
  5.     parent::configure();
  6.     $this->widgetSchema['user_id'] = new sfWidgetFormInputHidden();
  7.     $this->validatorSchema['user_id'] = new sfValidatorPropelChoice(array(
  8.       'model' => 'User', 'column' => 'id', 'required' => false
  9.     ));
  10.   }
  11.  
  12.   protected function setQuestionTagForm()
  13.   {
  14.     $this->questionTagForm = new QuestionTagForm(null);
  15.   }
  16.  
  17.   public function updateDefaultsFromObject()
  18.   {
  19.     parent::updateDefaultsFromObject();
  20.     $tag = implode(" ", $this->object->getTags());
  21.     $values['tag'] = $tag;
  22.     if ($this->isNew) {
  23.       $this->setDefaults(array_merge($values, $this->getDefaults()));
  24.     } else {
  25.       $this->setDefaults(array_merge($this->getDefaults(), $values));
  26.     }
  27.   }
  28. }

As I saild above, I overrided the setQuestionTagFrom for without auto completing function.

O.K, now it's about time for tagging.

lib/form/QuestionTagForm.class.php

  1. class QuestionTagForm extends BaseQuestionTagForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['user_id'],
  7.       $this['normalized_tag'],
  8.       $this['created_at']
  9.     );
  10.  
  11.     $this->widgetSchema['question_id'] = new sfWidgetFormInputHidden();
  12.     $this->validatorSchema['question_id'] = new sfValidatorPropelChoice(array('model' => 'Question', 'column' => 'id', 'required' => false));
  13.  
  14.     $url = $this->getOption('url');
  15.     if (!empty($url)) {
  16.       $this->widgetSchema['tag'] = new sfWidgetFormProtoculousAutocompleter(array(
  17.         'label' => 'tags:',
  18.         'url' => $url,
  19.         'use_style' => true
  20.       ));
  21.     } else {
  22.       $this->widgetSchema['tag'] = new sfWidgetFormInput(array(
  23.         'label' => 'tags:'
  24.       ));
  25.     }
  26.     $this->widgetSchema->setNameFormat('question[%s]');
  27.   }
  28.  
  29.   public function doSave($con = null)
  30.   {
  31.     $tags = Tag::splitPhrase($this->taintedValues['tag'] . (sfConfig::get('app_permanent_tag') ? ' '.sfConfig::get('app_permanent_tag') : '' ));
  32.     $question = QuestionPeer::retrieveByPk($this->taintedValues['question_id']);
  33.  
  34.     foreach ($tags as $tag) {
  35.       try {
  36.         $questionTag = new QuestionTag();
  37.         $questionTag->setQuestionId($question->getId());
  38.         $questionTag->setUserId($question->getUserId());
  39.         $questionTag->setTag($tag);
  40.         $questionTag->save($con);
  41.       } catch (PropelException $e) {
  42.         // do nothing
  43.       }
  44.     }
  45.     // hmm, i don't like this... :(
  46.     $this->object = $question;
  47.   }
  48. }

To create auto complete widget, I made sfWidgetFormProtoculousAutocompleter. There was JavaScript helper for auto complete. However, I do not want to use it, because I prefer to use sfForm. :) Also symfony1.2 integrated some useful function with jQuery. :) jQuery is a wonderful library, but I hate use two different JavaScript library in one application. If I use jQuery, I only use jQuery, and askeet has a lot of prototype base code, so I created autocompletor widget for sfProtoculousPlugin. :) Well, it was easy anyway.
So, if there was url option when intiating QuestionTagForm, use sfWidgetFormProtoculousAutocompleter. Without option, use another sfWigetFormInput.

Actually I do not like my implementation of doSave. As I saild above, you need to set more than one tag same time when posting. So, I need to save all the tags. Because saving more than one tag, i don't know what to return. I just return the question object here. :(

Good thing about this QuestionTagForm, you can use this both mergeForm and only itself. As I mentioned above, there are two ways to tag, and both are using this class.

lib/form/sfWidgetFormProtoculousAutocompleter.class.php

  1. class sfWidgetFormProtoculousAutocompleter extends sfWidgetFormInput
  2. {
  3.   protected function configure($options = array(), $attributes = array())
  4.   {
  5.     $this->addRequiredOption('url');
  6.     $this->addOption('config', '{ }');
  7.     $this->addOption('use_style');
  8.  
  9.     parent::configure($options, $attributes);
  10.   }
  11.  
  12.   public function render($name, $value = null, $attributes = array(), $errors = array())
  13.   {
  14.     $response = sfContext::getInstance()->getResponse();
  15.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/prototype');
  16.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/effects');
  17.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/controls');
  18.  
  19.     if ($this->getOption('use_style')) {
  20.       $response->addStylesheet(sfConfig::get('sf_prototype_web_dir').'/css/input_auto_complete_tag');
  21.     }
  22.     return parent::render($name, '', $attributes, $errors) .
  23.       content_tag('div' , '', array('id' => $this->generateId($name) . '_autocomplete', 'class' => 'auto_complete')) .
  24.       sprintf('<script type="text/javascript">new Ajax.Autocompleter("%s", "%s", "%s", %s);</script>',
  25.         $this->generateId($name),
  26.         $this->generateId($name . '_autocomplete'),
  27.         $this->getOption('url'),
  28.         $this->getOption('config')
  29.       );
  30.   }
  31. }

I was a bit lazy, so above code is not well written. :( That's all for form code, and let's see the action and templates from here.

/apps/frontend/modules/questions/actions/actions.class.php

  1. // snip
  2.  public function executeNew(sfWebRequest $request)
  3.   {
  4.     $this->form = new QuestionForm();
  5.   }
  6.   public function executeCreate(sfWebRequest $request)
  7.   {
  8.     $this->form = new QuestionForm();
  9.     $this->form->bind($request->getParameter($this->form->getName()));
  10.     if ($this->form->isValid()) {
  11.       $this->form->save();
  12.       $this->redirect($this->generateUrl('question_show', $this->form->getObject()));
  13.     }
  14.     $this->setTemplate('new');
  15.   }
  16. // snip

executeNew and executeCreate is very simple, and probably, you don't need any instruction with this. Good thing about mergeForm is you don't need to know you are using tag form in action class, because we have already merge it in QuestionForm class. :) You just render the quetion form, and bind and save it.

apps/frontend/modules/question/template/newSuccess.php

  1. <h1><?php echo __('ask a question') ?></h1>
  2.  
  3. <div class="in_form">
  4.   <p>
  5.   <?php echo __('Have you looked for similar questions? Check if a related question already exists: The more interesting a question is, the more people will be willing to answer it.') ?>
  6.   </p>
  7.   <p>
  8.   <?php echo __('Be as accurate as you can when giving a title to your question. Keep it short and put the details in the question body.') ?>
  9.   </p>
  10. </div>
  11.  
  12. <form action="<?php echo url_for('question') ?>" method="post" class="form">
  13.   <fieldset>
  14.     <?php echo $form['title']->renderError() ?>
  15.     <?php echo $form['title']->renderLabel() ?>
  16.     <?php echo $form['title'] ?>
  17.     <br class="clearleft" />
  18.  
  19.     <?php echo $form['body']->renderError() ?>
  20.     <?php echo $form['body']->renderLabel() ?>
  21.     <?php echo $form['body'] ?>
  22.     <br class="clearleft" />
  23.     <?php echo include_partial('content/markdown_help') ?>
  24.  
  25.     <?php echo $form['tag']->renderError() ?>
  26.     <?php echo $form['tag']->renderLabel() ?>
  27.     <?php echo $form['tag'] ?>
  28.     <br class="clearleft" />
  29.     <div class="small in_form"><?php echo __('example: askeet "how to"') ?></div>
  30.  
  31.   </fieldset>
  32.  
  33.   <div class="right">
  34.     <input type="submit" value="<?php echo __('ask it') ?>" />
  35.   </div>
  36. </form>

View file is also simple. Because we merge the QuestionTagForm, you can use $form['tag'], here! O.K, now you are able to post a question with tagging with auto complete feature!

O.K, it is about time to write about tag form that you find in sidebar. We will use the same QuestionTagForm here. :) To add tag, you have to be authenticated and go to the question/show page. In the component slot, _question.php, you include tag/add component. This component displays the tagging form.

/apps/frontend/modules/tag/actions/components.class.php

  1. class tagComponents extends sfComponents
  2. {
  3.   public function executeAdd(sfWebRequest $request)
  4.   {
  5.     $questionTag = new QuestionTag();
  6.     $questionTag->setQuestionId($this->question_id);
  7.     $this->form = new QuestionTagForm($questionTag, array('url' => $this->generateUrl('tag_autocomplete')));
  8.   }                                                                             
  9. }

In the action, executeAdd, we set QuestionTag object with question_id. Plus auto_complete url is also set when initialize form class. As you can see the template file, this form is ajax form.

/app/frontend/modules/tag/templates/_add.php

  1. <?php if ($sf_user->isAuthenticated()): ?>
  2. <div><?php echo ('Add your own:') ?>
  3. <?php echo form_remote_tag(array(                                             
  4.   'url'    => 'tag_create',
  5.   'update' => 'question_tags',
  6.   'complete' => '$("ask_question_tag_tag").value=""'
  7. )) ?>
  8. <?php echo $form['question_id'] ?>
  9. <?php echo $form['tag'] ?>
  10. <input type="submit" value ="<?php echo __('tag') ?>" />
  11. </form>
  12. </div>
  13. <?php endif; ?>

This form request action goes tag_create rule, which is tag/executeCreate action.

apps/frontend/modules/actions/tag/actions/actions.class.php

  1. // snip
  2.  public function executeCreate(sfWebRequest $request)
  3.   {
  4.     $form = new QuestionTagForm();
  5.     $form->bind($request->getParameter($form->getName()));
  6.     if ($form->isValid()) {
  7.       // xxx this form->save returns qustion object
  8.       $this->question = $form->save();
  9.     } else {
  10.       $this->question = $form->getObject()->getQuestion();
  11.     }
  12.     $this->tags = $this->question->getTags();
  13.   }
  14. // snip

As I mentioned above QuestionTagForm's doSave method returns not QuestionTag object, but Question Object, which is not really nice I think. :( Anyway, let's render the tags value to the view file and return value for ajax request. In createSuccess.php, it is same as tag/question_tags partial. What you need to do here is just updating the question tags list which has just tagged by you.

apps/frontend/modules/actions/tag/actions/actions.class.php

  1. // snip
  2.  public function executeAutocomplete(sfWebRequest $request)
  3.   {
  4.     $this->tags = array();
  5.     $form = new QuestionTagForm();
  6.     $form->bind($request->getParameter($form->getName()));
  7.     if ($form->isValid()) {
  8.       $values= $form->getTaintedValues();
  9.  
  10.       $this->tags = QuestionTagPeer::getTagsForUserLike(
  11.         $this->getUser()->getSubscriberId(),
  12.         $values['tag'],
  13.         10
  14.       );
  15.     }
  16.   }
  17. // snip

March 29, 2009

Playing with sfForm with Askeet Part 1

It took time to write a new post. :( My current project had a tight schedule, and it is very important for me to work on this project. Sorry. ;)

So, in this post, I will explain some tips for sfForm using the code I implemented in Askeet. I will explain these two cases for form handling:

  • user login / registration switching
  • question posting with tags and tags posting(next post)

To explain the code, I will use following files.

  • lib/form/UserLoginForm.class.php
  • lib/form/UserMixForm.class.php
  • apps/frontend/modules/user/actions/actions.class.php(executeNew, executeLogin, executeCreate
  • apps/frontend/templates/layout.php
  • apps/frontend/modules/user/actions/components.class.php
  • apps/frontend/modules/user/templates/login.php
  • apps/frontend/modules/user/templates/newSuccess.php

Before I explain the code I need to clarify the specification of user login and registration process in Askeet. There are two ways to login Askeet:

  • For unauthorized users, when some actions require authentication, such as posting questions and answers, voting for interest, moderating and etc, the login form dynamically appears on the page. Like following image.
  • Following signin/register link lead you the page you can login and register. When login failed by both ways this page is used as well. Like following image.

O.K, let's start from the first one. Because I'm using symfony1.2, I should use sfForm instead writing every input tags or tag_helper in the view template. I didn't know how to implement such form, but I got a good idea from Exgear's tutorial, and I followed the instruction.
Here, I wrote some code in UserLoginForm.class.php, layout.php, components.class.php, and its template, _login.php. The code are follows:

UserLoginForm.class.php

  1. class UserLoginForm extends BaseUserForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['id'],
  7.       $this['created_at'],
  8.       $this['first_name'],
  9.       $this['last_name'],
  10.       $this['email'],
  11.       $this['sha1_password'],
  12.       $this['salt'],
  13.       $this['ask_interest_list'],
  14.       $this['ask_relevancy_list']
  15.     );
  16.     $this->validatorSchema['nickname'] = new sfValidatorString(
  17.       array('min_length' => 5),
  18.       array('required' => 'your nickname is required', 'min_length' => 'nickname must be 5 or more characters')
  19.     );
  20.  
  21.     $this->widgetSchema['password'] = new sfWidgetFormInputPassword();
  22.     $this->validatorSchema['password'] = new sfValidatorString(
  23.       array(),
  24.       array('required' => 'your password is required')
  25.     );
  26.     $this->widgetSchema['referer'] = new sfWidgetFormInputHidden();
  27.     $this->validatorSchema['referer'] = new sfValidatorPass();
  28.     $request = sfContext::getInstance()->getRequest();
  29.     if ($request->isMethod('get')) {
  30.       $this->setDefault('referer', $request->getReferer());
  31.     } else {
  32.       $this->setDefault('referer', $request->getParameter($this->getName().'[referer]'));
  33.     }
  34.  
  35.     // check valid user
  36.     $this->validatorSchema->setPostValidator(new sfValidatorCallback(array(
  37.       'callback' => array($this, 'validUser'),
  38.     )));
  39.  
  40.     $this->validatorSchema->setOption('allow_extra_fields', true);
  41.   }
  42.  
  43.   public function validUser($validator, $values)
  44.   {
  45.     if (empty($values['nickname']) or empty($values['password'])) {
  46.       return;
  47.     }
  48.     $nickname = $values['nickname'];
  49.     $c = new Criteria();
  50.     $c->add(UserPeer::NICKNAME, $nickname);
  51.     $user = UserPeer::doSelectOne($c);
  52.  
  53.     if (is_null($user) || sha1($user->getSalt().$values['password']) != $user->getSha1Password()) {
  54.       throw new sfValidatorError($validator, 'no user available');
  55.     }
  56.     $this->object = $user;
  57.  
  58.     return $values;
  59.   }
  60. }

Well, this code is straightforward and not many tricks.If I have to say something, I would say using sfValidatorCallback to authorizing user request as post validator. Actually this idea is also from exgear's site. They are really good. :)
BTW, some might think why I put allow_extra_fields there? Well, I will explain this later. As it is, extra fields are expecting for UserLoginForm.

So, create apps/frontend/modules/user/actions/components.class.php, and render the form object of the class.

components.class.php

  1. class userComponents extends sfComponents                                       
  2. {
  3.   public function executeLogin(sfWebRequest $request)
  4.   {
  5.     $this->form = new UserLoginForm();
  6.   }
  7. }

Simple! Huh! :)

Now its view file, apps/frontend/modules/users/templates/_login.php

_login.php

  1. <form action="<?php echo url_for('login') ?>" method="POST" id="loginform">
  2. <?php echo $form['nickname']->renderLabel() ?> <?php echo $form['nickname'] ?>
  3. <?php echo $form['password']->renderLabel() ?> <?php echo $form['password'] ?>
  4. <?php echo $form['referer'] ?>
  5. <input type='submit' value="<?php echo __('login') ?>">
  6. <?php echo link_to_function(__('cancel'), visual_effect('blind_up', 'login', array('duration' => 0.5))) ?>
  7. </form>

As I am a programmer, I do not like to write every echo $form['blabla'] in view files, but I was too lazy to think other way right now. :( I might try better ways later if I had any chances. :) Anyway, this is very easy, so no explanation here either. Blinding up animation is there, because it was found in the Askeet tutorial, and I liked the cool animation. :) I did not write any error message in this template, you will see why later.

Now you need to include this components in layout.php

layout.php

  1. // snip
  2.       <div id="login" style="display: none">
  3.         <h2><?php echo __('please sign-in first') ?></h2>
  4.         <?php include_component('user', 'login') ?>
  5.       </div>
  6. // snip

Now you can see the form container is invisible, and use link_to_function to will change the status to visible. When actions need user authentication, echo the next helper function, and the login form will appear!

  1. link_to_function('some action label', visual_effect('blind_down', 'login', array('duration' => 0.5)));

O.K, that's one way to login for user. It was longer than I thought. :) We didn't reach the tricky tips yet. Now you might think "what about login error page? How do users know if he or she typed right nickname and password? ", Well, those error messages are displayed signin /register page. So, when user login failed, you have different login page, which I will explain from now.

For easy user registration and login, many web applications use the same form to login or registering. Askeet also use this strategy. The default page of signin/register page only displays nickname and password fields, and a checkbox. Yes, this checkbox having label as "click here to create a new account", switches this form action to be login or registering. Look at the image again.

If you check the checkbox, two more fields will appear in the page like following image:

O.K, finally! I had struggled a bit to implementing this with sfForm. Askeet tutorial was easy, because the tutorial write the input helper directly. I'm using sfForm framework, and I want to avoid this. To implement this form, I created UserMixForm.class.php for this page. UserLoginForm.class.php was for only login, but this form class is for registration mainly. Maybe naming is not good, though :(
Anyway, I copy all the source of UserMixForm.class.php here:

  1. class UserMixForm extends BaseUserForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['id'],
  7.       $this['created_at'],
  8.       $this['first_name'],
  9.       $this['last_name'],
  10.       $this['sha1_password'],
  11.       $this['salt'],
  12.       $this['ask_interest_list'],
  13.       $this['ask_relevancy_list']
  14.     );
  15.  
  16.     $this->widgetSchema['nickname'] = new sfWidgetFormInput(array('label' => 'Nickname:'));
  17.     $this->validatorSchema['nickname'] = new sfValidatorString(
  18.       array('min_length' => 5),
  19.       array('required' => 'your nickname is required', 'min_length' => 'nickname must be 5 or more characters')
  20.     );
  21.  
  22.     $this->widgetSchema['password'] = new sfWidgetFormInputPassword(array('label' => 'Password:'));
  23.     $this->validatorSchema['password'] = new sfValidatorString(
  24.       array(),
  25.       array('required' => 'your password is required')
  26.     );
  27.     $controller = sfContext::getInstance()->getController();
  28.     $this->widgetSchema['new'] = new sfWidgetFormInputCheckbox(array(
  29.       'label' => 'click here to create a new account'
  30.       ),
  31.       array('style' => 'display: inline; float: none',
  32.         'onclick' => "
  33.       if (Element.visible('new_account')) {
  34.         Effect.BlindUp('new_account');
  35.         $('login_form').action = '" . $controller->genUrl('login') . "';
  36.        } else {
  37.         Effect.BlindDown('new_account');
  38.         $('login_form').action = '" . $controller->genUrl('user_create') . "';
  39.        }
  40.     "));
  41.     $this->validatorSchema['new'] = new sfValidatorPass();
  42.  
  43.     $this->widgetSchema['password_biz'] = new sfWidgetFormInputPassword(
  44.       array('label' => 'confirm your password')
  45.         );
  46.     $this->validatorSchema['password_biz'] = new sfValidatorString(
  47.       array(),
  48.       array('required' => 'password confirmation is required')
  49.     );
  50.  
  51.     $this->widgetSchema['email'] = new sfWidgetFormInput(
  52.       array('label' => 'your email')
  53.     );
  54.     $this->validatorSchema['email'] = new sfValidatorEmail(
  55.       array('min_length' => 5),
  56.       array(
  57.         'required' => 'your email is required',
  58.         'invalid' => 'email address is invalid'
  59.       )
  60.     );
  61.  
  62.     $this->validatorSchema->setPostValidator(new sfValidatorAnd(array(
  63.       new sfValidatorSchemaCompare(
  64.         'password', '==', 'password_biz', array(),
  65.         array('invalid' => 'password and confirmed password does not match')
  66.       ),
  67.       new sfValidatorPropelUnique(
  68.         array('model' => 'User', 'column' => array('nickname')),
  69.         array('invalid' => 'the nickname is already taken.')
  70.       ),
  71.       new sfValidatorPropelUnique(
  72.         array('model' => 'User', 'column' => array('email')),
  73.         array('invalid' => 'the email address is areadly registered.')
  74.       )
  75.     )));
  76.  
  77.     $this->widgetSchema['referer'] = new sfWidgetFormInputHidden();
  78.     $this->validatorSchema['referer'] = new sfValidatorPass();
  79.     $request = sfContext::getInstance()->getRequest();
  80.     if ($request->isMethod('get')) {
  81.       $this->setDefaults(array('referer' => $request->getReferer()));
  82.     }
  83.   }
  84.  
  85.   public function updateObject($values = null)
  86.   {
  87.     $object = parent::updateObject($values);
  88.     $password = $this->taintedValues['password'];
  89.     $object->setPassword($password);
  90.     return $object;
  91.   }
  92.  
  93.   public function mergeStatus($loginform)
  94.   {
  95.     if (isset($loginform)) {
  96.       $nickname = $loginform->taintedValues['nickname'];
  97.       $referer = $loginform->taintedValues['referer'];
  98.       $this->errorSchema = $loginform->getErrorSchema();
  99.       $this->setDefault('nickname', $nickname);
  100.       $this->setDefault('referer'$referer);
  101.     }
  102.   }
  103. }

Wow, this is long, but the longest method, configure, does not need any explanation I think. The method, updateObject call setPassword, because there is no password field for user model. There are sha1_password and sha1 fields. So, setPassword of user model set both fields. Actually it is same as Askeet tutorial, so I skip this, too. I just need to update the field, so I overrided the method.

What about mergeStatus method? Well, this is something new. Hehehe. :) Because this is my original idea. I did not refer any site to implement this, and I believe this is a tricky part. As I mentioned above, UserMixForm is actually for user registration. However, this form also handles user login and displaying login error messages as well. To succeed the login failure messages and input values, this method was needed. You will see the reason soon.

So, the signin/register page is assigned to executeNew method in actions. executeCreate is for binding, validating, and saving the user registration form. Finally executeLogin binding, validating login authentication, and if the validation failed it will render the error messages as well. O.K, let's see the code :)

  1. // snip
  2.   public function executeNew(sfWebRequest $request)
  3.   {
  4.     if ($this->getUser()->isAuthenticated()) {
  5.       $this->redirect('homepage');
  6.     }
  7.     $this->form = new UserMixForm();
  8.   }
  9.  
  10.   public function executeCreate(sfWebRequest $request)
  11.   {
  12.     $this->form = new UserMixForm();
  13.     $this->form->bind($request->getParameter($this->form->getName()));
  14.     if ($this->form->isValid()) {
  15.       $this->form->save();
  16.       $this->forward('user', 'login');
  17.     }
  18.     // use user registration form in login template
  19.     $this->getRequest()->setAttribute('newaccount', true);
  20.     $this->setTemplate('new');
  21.   }
  22.   public function executeLogin(sfWebRequest $request)
  23.   {
  24.     $loginform = new UserLoginForm();
  25.     $loginform->bind($request->getParameter($loginform->getName()));
  26.     if ($loginform->isValid()) {
  27.       $this->getUser()->signIn($loginform->getObject());
  28.       $this->redirect($request->getParameter($loginform->getName() .'[referer]', 'homepage'));
  29.     }
  30.     $this->form = new UserMixForm();
  31.     $this->form->mergeStatus($loginform);
  32.     $this->setTemplate('new');
  33.   }
  34.  
  35. // snip

The method, executeNew does not need any explanation. It just renders the UserMixForm object. I don't want authorized user to access this page, so let them forward to homepage route. O.K. the method, executeCreate is also straightforward. It is just another form transaction (bind, validate, save). In case the validation failed, set newaccount attribute for checkbox to be checked, and use the same template as executeNew.

O.K, finally! We are in tricky part, executeLogin. Because UserMixForm and UserLoginForm are children of BaseUserForm class, both share same fields, such as nickname, password. So, UserLoginForm require nickname, password, referer, and the for send the parameters. UserMixForm also send nickname, password, referer(and other fields, too). Well, yes! UserMixForm has other fields. That's why allow_extra_fields is on in UserLoginForm. If the user authentication failed, this action uses newTemplate which means using UserMixForm. You need to render the form value, and you need to set the error messages and input values from the last request to the form as well. To do this, you have to pass them from userLoginForm to UserMixForm. Here is mergeStatus method. I don't know if this name is proper, though. :(

Oops, I forgot to mention newSuccess.php.

newSuccess.php

  1. <h1><?php echo __('sign in / register') ?></h1>
  2.  
  3. <div class="in_form">
  4. <p><?php echo __('Registration is free and required only to create a new question or rate an answer.') ?></p>
  5. </div>
  6. <?php echo $form->renderGlobalErrors() ?>
  7. <form action="<?php echo $sf_request->getAttribute('newaccount', false) ? url_for('user_create') : url_for('login') ?>" method="POST" id="login_form" class="form">
  8.   <fieldset>
  9.     <?php echo $form['nickname']->renderLabel() ?>
  10.     <?php echo $form['nickname']->renderError() ?><?php echo $form['nickname'] ?>
  11.     <br class="clearleft"/>
  12.     <?php echo $form['password']->renderLabel() ?><?php echo $form['password']->renderError() ?>
  13.     <?php echo $form['password'] ?>&nbsp;<?php echo link_to(__('forgot your password?'), 'user_require_password') ?>
  14.     <br class="clearleft"/>
  15.     <div class="in_form">
  16.       <?php echo $form['new'] ?>
  17.       &nbsp;<?php echo $form['new']->renderLabelName() ?>
  18.     </div>
  19.     <br class="clearleft"/>
  20.     <div id="new_account"<?php echo $sf_request->getAttribute('newaccount', false) ? '' : ' style="display: none"' ?>>
  21.       <?php echo $form['password_biz']->renderLabel() ?>
  22.       <?php echo $form['password_biz']->renderError() ?>
  23.       <?php echo $form['password_biz'] ?>
  24.       <br class="clearleft"/>
  25.  
  26.       <?php echo $form['email']->renderLabel() ?>
  27.       <?php echo $form['email']->renderError() ?>
  28.       <?php echo $form['email'] ?>
  29.       <br class="clearleft"/>
  30.  
  31.       <div class="small in_form"><?php echo __('askeet will never disclose this address to a third party') ?></div>
  32.     </div>
  33.   </fieldset>
  34.   <?php echo $form['referer'] ?>
  35.   <div class="right">
  36.   <input type='submit' value="<?php echo __('sign in') ?>">
  37.   </div>
  38. </form>

It is another usual form. No explanation.

Because there are difficulties to explain in English (Probably it would be hard to write this topic in Japanese anyway), I may fix some words or explanation I posted today.

Anway, that's it. Wow, this is a long post, I have to split the tips to two posts. Hmm. Next is Question and QuestionTag form handling. Have fun! :) :) :) :)

March 10, 2009

How about sfAskeetPlugin!

I was talking with akky a few minutes ago, and he had a great idea!

It was posting askeet as symfony plugin like a sfJobeetPlugin!

I'm going to work on this for my free time, so it might take a little time. Please wait!

BTW, I replaced the tar ball, because I found a bug. Answering by anonymous user broke the table constraint, so I fixed some code.

I need more unit / functional testing. :)

March 9, 2009

Askeet with symfony1.2

O.K, this is my first post in English. I will try my best. :)

After symfony1.2 launched, Jobeet was introduced to the world for users to learn through developing it. The tutorial is very effective for symfony users. I also did the tutorial and learned a lot of symfony1.2 new feature.

Back to 2005, when symfony emerged, there was an application like Jobeet for symfony learners. The application was Askeet! I have used symfony since symfony0.6, and learned a lot from the Askeet tutorial that time. However, the tutorial had some small mistakes, and the source code you can get from svn repository and tar ball was a bit buggy. :( I struggled a lot to complete the tutorial. Maybe Fabien and Francois did it on purpose to motivate us to investigate inside of symfony further. Hehe. ;) Currently nobody care the Askeet tutorial, because there is Jobeet, and Askeet tutorial is obsolete, since symfony is now 1.2!

Is Askeet just a tutorial? Even Askeet is an open source application, I have not encountered any web sites integrate Askeet. Why? I have some reasons in my mind. First, as I wrote on above, it is a bit buggy, such as same duplicate question title cause error, user_interest rule and related actions are never used, and etc. Second, even there was a cache chapter in the tutorial, more caching is needed for real world applications. Third, symfony is growing rapidly, which is good, but applications written by old version of symfony are hard to be taken care. Forth, there is no active source code repository for the application. Of course there is a repository for tutorial, but not for the application.

I was waiting for that somebody develops Askeet with symfony1.2, and give us its tutorial. Actually there were a couple tries in Japan, and I was excited about their work to finish.... BUT, after symfony team introduced Jobeet, everybody went to Jobeet, and the tries are gone to somewhere. :(

So, I did it! :)

Well, since I am too lazy, I'm not going to write tutorials for beginners, but I will give you some tips I found from the experience that I was developing Askeet with symfony1.2. I'm still working on caching strategy for this application, so any help is welcome. Actually my friend, akky, is helping for i18n now.

O.K, for today, I give you the current source code. License? Same as symfony! I'm using symfony1.2.4(current version:3/10/09). You can learn a lot of symfony features from the code I believe. Why I open the source code? Well, there are some reasons. First I love symfony and would like to contribute to symfony community, and second, askeet was an open source application, and why not to. Third, to show how much I can do. People will know my current skill, so that I might get job requests in the future. ;) Finally, even I open the code, I know I am the one who know the code most. :)

You can download the source code from here:Askeet with symfony1.2

O.K. In a few weeks, I will probably post following topics which I found from developing Askeet with symfony1.2:

  • Playing with sfForm
  • Building complex Criteria
  • Cross filtering between different models in Admin Generator
  • Command task from Web

In English version, it works good. However my language, Japanese, has some problems because of full text search and multibyte issues. We are working on it now, and probably, I will post the fixed one later. Well, I need a svn repository, where to use?

February 22, 2009

mergeFormかembedFormか

フォームオブジェクトを使う際にはどちらを使うのがいいのかな、と私も瞬間悩んだけど、sfForm.class.phpやsfPropelForm.class.phpを読んでみると、結構動作が違うね。embedFormForEachはembedFormをかぶせただけなので、それほど違いはないとは思う。未だにバグがあると私は思っているので、使う予定はないけども。

mergeFormの場合

  • 単純にフォームをくっつけたいときに使う
  • 具体的には、FormオブジェクトのDefault値とwidgetSchema、widgetValidatorのみmergeをしている
  • mergeをしているという名前のごとく、widgetのfield値が同じであった場合には、mergeされてしまうので、複数追加することができない
  • mergeされたFormオブジェクトの更新系のメソッド(updateObjectとかdoSave)が呼ばれることはないので、手元のフォームで呼んであげないといけないupdateDefaultFromObjectは、呼ばれる。コンストラクタ内なので

embedFormの場合

  • フォームを入れ子にしたいときに使う
  • embedされたFormオブジェクトは、sfFormクラスのインスタンスオブジェクト$embedFormsに格納される
  • 新しくfieldを指定して、その中にembedするので、embedFormの中のwidgetのfield値が同じであっても構わない(新しいfieldはユニークでなければいけないが)
  • embedされたFormオブジェクトのupdateObject, doSaveが呼ばれる

パッと見た感じ、似たようなもんかなー、と思っていたら、データの保存とかが結構違うのでmergeされたFormクラスのupdateObjectなどを独自で書いている際には、明示してあげないと呼ばれないので、ちょっとビックリする。呼ばれることを前提として書いていたので。。。

あと、sfGuardPluginのsfGuardUserAdminForm.class.phpには、mergeFormが出てくるけど、このupdateObjectってトランザクションを持たずにsaveメソッドを呼んでいるような気がするのだが。。。

というわけでちょっとした自分用のメモ。

February 12, 2009

symfony1.2 embedFormでファイルアップロード

symfonyでFormを使うときって、mergeFormやらembedFormって使っていますか?

私は、最近までは別々のFormクラスを作って、アクションクラスで使うFormクラスをtemplateにrenderしていました。はい。力業力業。よくないねぇ。

最近は、その辺を激しくリファクタリングして、前から気になっていたmergeFormやembedForm使ってみました。で、いろいろ使ってみると、これいいじゃない?というわけで、そのことをブログに書いてみることにします。

環境は、1.2.5-dev。まぁ、1.2.4でも動きます。embedFormForEachというのもあって、複数のフォームを使いたいときは、そちらを使う方がスマートのような感じがしますが、バグがあるせいか、どうもうまくいかないので、embedFormをループで回して使ってみることにしました。今回は、複数個アップロードするということで、mergeFormは扱わずに説明します。まぁ、いつか説明してもいいけど、こちらはすでにいくつもサンプルがあるので、そちらを勝手に調べてくださいな。私は、embedFormで画像のアップロードを扱うことにします。

超簡単なサンプルということで、スキーマはこんな感じでどうかしら?

  1. propel:
  2.   posts:
  3.     _attributes: { phpName: Post }
  4.     id:
  5.     title:
  6.       type: varchar(128)
  7.       required: true
  8.     description:
  9.       type: longvarchar
  10.       required: true
  11.     created_at:
  12.     updated_at:
  13.   pictures:
  14.     _attributes: { phpName: Picture }
  15.     id:
  16.     post_id:
  17.       type: integer
  18.       required: true
  19.       foreignTable: posts
  20.       foreignReference: id
  21.       onDelete: cascade
  22.       onUpdate: cascade
  23.     filename:
  24.       type: varchar(255)
  25.       required: true
  26.     caption:
  27.       type: varchar(128)
  28.       required: true

相変わらずpropel使っています。説明はいらないと思うけども、一応。

postsというテーブルと、picturesというテーブルを用意して、picturesにはpost_idというカラムを持たせて、postsテーブルにリレーションをさせます。まぁ、そんだけ。つまり、postsとpicturesは、1-N関係なのですね。

databases.ymlなどの設定をしておいて、propel:build-allをするとformクラスやmodelクラスの雛形を作成してくれますね。モジュールも手で作るのが面倒なのと今回の説明には大事なところではないので、propel:generate-module post Postで作っておきます。post/templates/_form.phpは、現在のPostFormオブジェクトによって、作成されるので、echo $formだけで超シンプルにしておきます。つまり、以下のようにしておいてください。

  1. <?php if (!$form->getObject()->isNew()): ?>                                     
  2. <input type="hidden" name="sf_method" value="put" />                           
  3. <?php endif; ?>                                                                 
  4.   <table>                                                                       
  5.     <tfoot>                                                                     
  6.       <tr>                                                                     
  7.         <td colspan="2">                                                       
  8.           <?php echo $form->renderHiddenFields() ?>                             
  9.           &nbsp;<a href="<?php echo url_for('post/index') ?>">Cancel</a>       
  10.           <?php if (!$form->getObject()->isNew()): ?>                           
  11.             &nbsp;<?php echo link_to('Delete', 'post/delete?id='.$form->getObject()->getId(), array('method' => 'delete', 'confirm' => 'Are you sure?')) ?>     
  12.           <?php endif; ?>                                                       
  13.           <input type="submit" value="Save" />                                 
  14.         </td>                                                                   
  15.       </tr>                                                                     
  16.     </tfoot>                                                                   
  17.     <tbody>                                                                     
  18.       <?php echo $form->renderGlobalErrors() ?>                                 
  19.       <?php echo $form ?>                                                       
  20.     </tbody>                                                                   
  21.   </table>                                                                     
  22. </form>

では、まずPostフォームを用意します。propel:build-allをするとbuild-formもついでにしてくれるので、lib/form/PostForm.class.phpができていると思います。これをちょいといじってみます。

  1. public function configure() {
  2.     unset($this['created_at'], $this['updated_at']);
  3.     $this->setWidgets(array(
  4.       'id' => new sfWidgetFormInputHidden(),
  5.       'title' => new sfWidgetFormInput(array(
  6.         'label' => 'タイトル',
  7.       )),
  8.       'description' => new sfWidgetFormTextarea(array(
  9.         'label' => '説明',
  10.       )),
  11.     ));
  12.  
  13.     $this->setValidators(array(
  14.       'id' => new sfValidatorPropelChoice(
  15.         array('model' => 'Post', 'column' => 'id', 'required' => false)
  16.       ),
  17.       'title' => new sfValidatorString(
  18.         array('max_length' => 128, 'min_length' => 3),
  19.         array(
  20.           'max_length' => 'タイトルは128文字以内でお願いします。',
  21.           'min_length' => 'タイトルは3文字以上でお願いします。'
  22.         )
  23.       ),
  24.  
  25.       'description' => new sfValidatorString(
  26.         array('max_length' => 2048, 'min_length' => 3),
  27.         array(
  28.           'max_length' => '説明本文は2000文字以内でお願いします。',
  29.           'min_length' => '説明本文は3文字以上でお願いします。'
  30.         )
  31.       ),
  32.     ));
  33.     $this->widgetSchema->setNameFormat('post[%s]');
  34.   }
  35. }

左の画像のようになりますね。まぁ、こんなもんでしょう。titleとdescriptionだけを入力する単純なフォームですね。バリデーションは適当に書いてみました。ごくごく初歩的なsfPropelFormの使い方だと思います。説明はいらないですね。

では、PictureFormも書いてみます。

  1. class PictureForm extends BasePictureForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset($this['post_id']);
  6.  
  7.     $captions = array('A', 'B', 'C', 'D');
  8.     $this->setWidgets(array(
  9.       'id' => new sfWidgetFormInputHidden(),
  10.       'filename' => new sfWidgetFormInputFileEditable(array(
  11.         'label' => false,
  12.         'delete_label' => 'ファイルを削除する',
  13.         'file_src' => '/uploads/' . $this->getObject()->getFilename(),
  14.         'is_image' => true,
  15.         'edit_mode' => !$this->isNew(),
  16.         'template' => '<div>%file%<br />%input%<br />%delete% %delete_label%</div>',
  17.       )),
  18.       'caption' => new sfWidgetFormSelect(
  19.         array(
  20.           'label' => '説明',
  21.           'choices' => array_combine($captions, $captions)
  22.         )
  23.       )
  24.     ));
  25.  
  26.     $this->setValidators(array(
  27.       'id' => new sfValidatorPropelChoice(array(
  28.         'model' => 'Picture', 'column' => 'id', 'required' => false
  29.       )),
  30.       'filename' => new sfValidatorFile(
  31.         array('required' => false, 'path' => sfConfig::get('sf_upload_dir')),
  32.         array(
  33.           'max_size' => 'ファイルサイズが大きすぎます。',
  34.           'mime_types' => '投稿できる画像フォーマットではありません。',
  35.           'partial' => 'ファイルアップロードに失敗しました。もう一度、投稿してください
  36.           。',
  37.           'no_tmp_dir' => 'システムエラーです。管理者にお伝えください。',
  38.           'cant_write' => 'システムエラーです。管理者にお伝えください。',
  39.           'extension' => 'システムエラーです。管理者にお伝えください。'
  40.         )),
  41.       'caption'    => new sfValidatorString(array(
  42.         'max_length' => 255, 'required' => false
  43.       )),
  44.       'filename_delete' => new sfValidatorPass()
  45.     ));
  46.     $this->widgetSchema->setFormFormatterName('list');
  47.   }
  48. }

これも説明の必要はないかな。symfony1.2からsfWidgetFormInputFileEditableというwidgetが追加されて、これがファイルアップロード関係でいろいろやってくれるのですね。便利になったものです。使い方はこのソースを読んでもいいですし、jobeetのadmin-generatorの章にも書いてありますので、それを参照してください。ここでは、画像だけをアップロードしてもいいのですが、せっかくなので、画像の説明としてcaptionというカラムもpictureテーブルに持たせてみることにします。A,B,C,Dというのはいいアイデアがなかったのとサンプルなので、まぁ、適当に。

これで、二つの独立したテーブルができましたね。しかし、postの投稿フォームにpictureも一緒に投稿させたいじゃないですか。さらに、pictureが複数投稿できたら尚良さそうですよね。ということで、PostFormにPictureFormをembedすることにします。しかも、複数。ここは決め打ちで3つとします。まぁ、サンプルなので。
PostFormのバリデーションの記述の後にでも、次のコードを追記してみましょう。

  1. $pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
  2.     for ($i = 0; $i <3; $i++) {
  3.       $picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
  4.       $pictureForm = new PictureForm($picture);
  5.       $this->embedForm('picture_' . $i, $pictureForm, '%content%');
  6.       $this->widgetSchema['picture_' . $i]->setLabel('画像');
  7.     }

少し説明しますと、Postが新規投稿であれば、何もしないですが、編集の際には、PictureFormに初期値を設定させて、それをembedFormしていきます。embedFormをする際には、picture_0, picture_1, picture_2というnameを与えて指定してみることにします。また、embedですから入れ子になりますし、ちょっと見た目がダサくなるのでデコレーションは、%content%にしておきます。ついでにlabelも指定しておきます。Formとして表示させるには、これだけ追加するだけで、左の画像のように、title, descriptionだけではなく、3つのPictureFormつまり、filename, captionが3つあるフォームができあがります。

ただ、これだけでは、このpictureがこのpostに関連づけられているかどうかがわからないので、PostFormのupdateObjectでsetPostしてあげます。

  1. public function updateObject($values = null)
  2.   {
  3.     $object = parent::updateObject($values);
  4.     $values = $this->getValues();
  5.  
  6.     foreach ($this->embeddedForms as $i => $picture) {
  7.       if ($picture->getObject()) {
  8.         if ($picture->getObject()->getFilename() == '') {
  9.           unset($this->embeddedForms[$i]);
  10.         } else {
  11.           $picture->getObject()->setPost($object);
  12.         }
  13.       }
  14.     }
  15.   }

ここは少し説明がいりますね。まず、PostのupdateObjectはそのまま親のメソッド呼んで、設定させておきます。embedFormでembedしたFormオブジェクトは、このPostFormクラスのembedFormsフィールドに配列で保持されているので(sfForm.class.php参照)、これをループで回して、どのpostと関連つけているかを指定してあげます。ついでにファイルがアップロードされない場合も保存されてしまいますので、getFilename()が空文字列の場合は、embedFormsからその要素をunsetしておきます。これで、ファイルが入っていたときのみ、保存されることになります。

これで新規投稿に関してはファイルがアップロードがちゃんとできるようになりました。また、編集時には、ちゃんとそのフォームに画像が表示されます。
しかし、このままでは実は編集時に使うfilename_deleteを使うことができないのですね。このままですと、そのpictureオブジェクトを削除せずにpictureオブジェクトのfilenameのカラムを空にしてくれるだけです。まぁ、当たり前と言えば当たり前か。ということで、ファイルを削除できるようにしてみます。

チェックボックスには、filename_deleteという名前を与えているので、フォームから渡ってきた値にfilename_deleteがあった場合には、そのPictureのオブジェクトをインスタンス変数のremovePicturesという配列に格納しておくことにします。そして、doSaveでDBをいじるときに一緒にremovePicturesにあるPictureオブジェクトを削除することにします。ということで、最終的にできあがったPostFormは以下のようになります。

  1. class PostForm extends BasePostForm
  2. {
  3.   private $removePictures = array();
  4.   public function configure() {
  5.     unset($this['created_at'], $this['updated_at']);
  6.  
  7.     $this->setWidgets(array(
  8.       'id' => new sfWidgetFormInputHidden(),
  9.       'title' => new sfWidgetFormInput(array(
  10.         'label' => 'タイトル',
  11.       )),
  12.       'description' => new sfWidgetFormTextarea(array(
  13.         'label' => '説明',
  14.       )),
  15.     ));
  16.  
  17.     $this->setValidators(array(
  18.       'id' => new sfValidatorPropelChoice(
  19.         array('model' => 'Post', 'column' => 'id', 'required' => false)
  20.       ),
  21.       'title' => new sfValidatorString(
  22.         array('max_length' => 128, 'min_length' => 3),
  23.         array(
  24.           'max_length' => 'タイトルは128文字以内でお願いします。',
  25.           'min_length' => 'タイトルは3文字以上でお願いします。'
  26.         )
  27.       ),
  28.  
  29.       'description' => new sfValidatorString(
  30.         array('max_length' => 2048, 'min_length' => 3),
  31.         array(
  32.           'max_length' => '説明本文は2000文字以内でお願いします。',
  33.           'min_length' => '説明本文は3文字以上でお願いします。'
  34.         )
  35.       ),
  36.     ));
  37.  
  38.     $pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
  39.     for ($i = 0; $i <3; $i++) {
  40.       $picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
  41.       $pictureForm = new PictureForm($picture);
  42.       $this->embedForm('picture_' . $i, $pictureForm, '%content%');
  43.       $this->widgetSchema['picture_' . $i]->setLabel('画像');
  44.     }
  45.  
  46.     $this->widgetSchema->setNameFormat('post[%s]');
  47.   }
  48.  
  49.   public function updateObject($values = null)
  50.   {
  51.     $object = parent::updateObject($values);
  52.     $values = $this->getValues();
  53.  
  54.     foreach ($this->embeddedForms as $i => $picture) {
  55.       if (isset($values[$i]['filename_delete'])) {
  56.         $this->removePictures[] = $picture->getObject();
  57.       } else if ($picture->getObject()->getFilename() == '') {
  58.         unset($this->embeddedForms[$i]);
  59.       } else {
  60.         $picture->getObject()->setPost($object);
  61.       }
  62.     }
  63.   }
  64.  
  65.   public function doSave($con = null)
  66.   {
  67.     parent::doSave($con);
  68.     foreach ($this->removePictures as $p) {
  69.       $p->delete($con);
  70.     }
  71.   }
  72. }

symfony1.2を使いこなせるかどうかのポイントの一つはsfFormがちゃんと使えるかどうかになると思います。今後もsfFormを追いかけてみようと思います。
propel:generate-module post Postで生成されたindexSuccess.phpも少しだけ修正して、ちゃんと一覧画面に表示させるようにてみますか。

  1. <h1>Post List</h1>
  2.  
  3. <table>
  4.   <thead>
  5.     <tr>
  6.       <th>Id</th>
  7.       <th>Title</th>
  8.       <th>Description</th>
  9.       <th>Created at</th>
  10.       <th>Pictures</th>
  11.     </tr>
  12.   </thead>
  13.   <tbody>
  14.     <?php foreach ($post_list as $post): ?>
  15.     <tr>
  16.       <td><a href="<?php echo url_for('post/edit?id='.$post->getId()) ?>"><?php echo $post->getId() ?></a></td>
  17.       <td><?php echo $post->getTitle() ?></td>
  18.       <td><?php echo $post->getDescription() ?></td>
  19.       <td><?php echo $post->getCreatedAt() ?></td>
  20.       <td>
  21.       <?php foreach ($post->getPictures() as $picture): ?>
  22.       <img src="/uploads/<?php echo $picture->getFilename() ?>" alt="<?php echo $picture->getCaption() ?>" width="100" />
  23.       <?php endforeach; ?>
  24.       </td>
  25.     </tr>
  26.     <?php endforeach; ?>
  27.   </tbody>
  28. </table>
  29.  
  30.   <a href="<?php echo url_for('post/new') ?>">New</a>

本当は、編集フォームページにあるDeleteボタンを押すと、ちゃんとデータベースからはPostとそれに関連するPictureが削除されるのですが、実際の画像はunlinkされないので、そこの修正も必要になりますね。編集フォームページで画像だけを削除する際には、sfPropelFormにあるremoveFileが呼ばれますので、unlinkされるのですが、単純にdeleteアクションだけを呼んだ場合には、formクラスとは別のロジックになりますので、ファイルは消えませんので、Pictureモデルのdeleteにでも書いて置かないといけなさそうですね。ちょっとredundantなので、微妙だなぁーと思って今回は、それは載せませんでした。つーか、そもそも削除させないとかw

今回は、サンプルでしたので、書きませんでしたが、画像をconvertしたりする必要があるかもしれないですね。私が作っている本番用では、同時にサムネイルを作ったり、もっとformが複雑だったり、キャプションの与え方が動的だったりします。しかし、embedFormのやり方はこのままですので、これを元にすれば、いろんなところに適応ができそうです。

今回は、symfony1.2を用いて、複数の画像投稿を実現するために、embedFormを用いて実装する方法を説明しました。確かにその複数分ループで回せばいいのですが、embedFormForEachがちゃんと動けば、そちらの方がスマートな気がします。それができるまでは、このembedFormで実現しましょう。

間違いがありましたら、教えてくださいな。

September 21, 2008

symfony1.1でswiftを使ってみた。

最近の開発は、ずっとsymfonyなので心中するしかないかなー、なんて思っていたりします。しかし、実際に使っていると、フレームワークではカバーしきれないコードを、ゴリゴリと、かっこ悪い方法で実装してしまったりして、嫌悪感いっぱいになってしまうことがあります。

その一つがメール周りです。symfony1.1からsfMailを使わない方向になりました(まぁ、使おうと思えば使えるけど)。そこで、代替とされているのが、Swift Mailerですね。直接SMTPが叩くので、速いだとか負荷が減るだとか、ほげほげだということで、swiftが採用されているようですが、個人的な感想を言えば、私はsfMailerで十分間に合っていたと思います。。。

とも言っていられないので、ちょっとswiftを使ってみました。でも、
How to send emails in symfony 1.1The symfony Cookbook How to send an emailを読んでもなんかしっくり来ないんですよね。理由は、これらのハウツーには、メールの本文をコントローラに書く方法しか提供していないからだと思います。確か前のsfMailerではViewテンプレートにメールの本文が書けたのですが、それってswiftでどうやったらいいのかなー、なんて思って調べてやってみました。まぁ、Jonathan Wageさんのアイデアなんだけどね。

というわけで、彼のアイデアをまとめると次のような感じか。
メールを使うアクションクラスをすべてsfSwiftMailerActionsの子クラスとする。
実際に呼び出す際には、親クラスの実装メソッドsendMailにモジュール名とアクション名を渡して、そのテンプレートに本文を書く。

  1. <?php
  2. class sfSwiftMailerActions extends sfActions
  3. {
  4.   public function preExecute()
  5.   {
  6.     $mailVars = sfSwiftMailerVars::getInstance()->getAll();
  7.  
  8.     foreach ($mailVars as $key => $value)
  9.     {
  10.       $this->$key = $value;
  11.     }
  12.   }
  13.  
  14.   public function sendEmail($module, $action)
  15.   {
  16.     sfConfig::set('symfony.view.' . $module . '_' . $action . '_layout', false);
  17.     $body = sfContext::getInstance()->getController()->getPresentationFor($module, $action);
  18.  
  19.     $mailVars = sfSwiftMailerVars::getInstance();
  20.  
  21.     $message = $mailVars->has('message')
  22.       ? $mailVars->get('message')
  23.       : new Swift_Message(null, null, 'text/html');
  24.     $swift = $mailVars->has('swift')
  25.       ? $mailVars->get('swift')
  26.       : new Swift(new Swift_Connection_Sendmail(Swift_Connection_Sendmail::AUTO_DETECT));
  27.  
  28.     $message->setSubject($mailVars->get('subject'));
  29.     $message->setBody($body);
  30.  
  31.     $swift->send($message, $mailVars->get('recipients'), $mailVars->get('from'));
  32.     $swift->disconnect();
  33.  
  34.     $mailVars->clear();
  35.   }
  36.  
  37.   public function __set($key, $value)
  38.   {
  39.     sfSwiftMailerVars::getInstance()->set($key, $value);
  40.  
  41.     return parent::__set($key, $value);
  42.   }
  43.  
  44. }
  45.  
  46. class sfSwiftMailerVars extends sfParameterHolder
  47. {
  48.   static $instance = null;
  49.  
  50.   public static function getInstance()
  51.   {
  52.     if (!self::$instance)
  53.     {
  54.       self::$instance = new sfSwiftMailerVars();
  55.     }
  56.  
  57.     return self::$instance;
  58.   }
  59.  
  60. }

ふむ。なるほど。テンプレートの内容を取るだけななら

  1. $body = sfContext::getInstance()->getController()->getPresentationFor($module, $action);

でできてしまうのですね。

私が手元で実装したものでは、もう少し劣化させて、上記とは違って、SwiftクラスやSwift_Messageクラスは直書きでやってしまっています。まぁ、デフォルトの挙動なんていらないと思うので。というわけで、このソースはテストはしていません。ほぼ同じものを手元で書いて、その動作は確認しているので、だいたいのロジックはこれでいいようです。

あとは、実際のアクションクラスにこんな感じで書けばいいのですね。

  1. class hogeActions extends sfSwiftMailerActions
  2. {
  3.   public function executeEdit($request)
  4.   {    $swift->send($message, $mailVars->get('recipients'), $mailVars->get('from'));
  5.     $this->recipients = 'example@ganchiku.com';
  6.     $this->from = 'example@ganchiku.com';
  7.     $this->subject = 'hello world';
  8.     $this->sendEmail('hoge', 'confirm');
  9.   }
  10.  
  11.   public function executeConfirm($request)
  12.   {
  13.   }
  14. }

で、あとは、confirmSuccess.phpに本文を適当に書けばいいのですね。ふーむ。確かに、すっきりはしますが、結構面倒ですね。。。もっといい方法があったら教えてください。

August 4, 2008

symfony1.1でsfGuardPluginを使う。パート2

ここ二日通っているベトナム料理のレストランの眼鏡っ子ウェイトレスがかわいくて、通おうかなーと思ってる私は、いろんな人に助けれながらハワイ島のヒロに滞在しています。ぜんぜん関係ないですが、今日は相撲の土俵の屋根の解体作業を手伝いました。きつい肉体労働で疲れました。。。

さて、パート1では、sfGuardPluginのsfGuardUserProfileとsfGuardUserを1対1のテーブル関係でリレーションがあった際に、アドミンジェネレータで一つのモデルをCRUDしているかのように扱う方法を説明しました。

しかし、実はこのアドミンジェネレータの方法は、symfony1.1的には、フォームの使い方が古いんですね。symfony1.1からは、sfFormクラスを使ってフォームを作るように、方法が変わったのです。なので、form_tagとかのヘルパー関数を使うのではなく、sfFormクラスの拡張クラスを使うことになります。また、Propelを使用している際には、うまく同期をとってくれるsfFormPropelの拡張クラスを使用することになりますね。例によって、ジェネレータで、ベースクラスと実際の処理を書く空クラスが作られ、それを上書きしていくという方法で実装するようになります。

さて、前提条件をここでおさらいするのは面倒なので、前回のパート1のまま、進めてみます。つまり、schema.ymlにsf_guard_user_profileを用意して、build-allをした状態とします。パート1では、backendとしましたが、今回は、frontendとしてみます。

今回のフォーム作成においては、CRUDのCreationのフォームとその値のデータベースへの登録についてを説明します。

パート1と同じように1対1の関連を持つ二つのテーブルを同時に登録する際の使い方について書きます。モジュール名はなんでもいいのですが、ベタにuserとしておきます。

  1. $ ./symfony project:init-module frontend user
  2. PHP Warning:  Xdebug MUST be loaded as a Zend extension in Unknown on line 0
  3. >> dir+      /home/shin/project/test/apps/frontend/modules/user/templates
  4. >> file+     /home/shin/project/test/apps/fr...user/templates/indexSuccess.php
  5. >> dir+      /home/shin/project/test/apps/frontend/modules/user/actions
  6. >> file+     /home/shin/project/test/apps/fr.../user/actions/actions.class.php
  7. >> file+     /home/shin/project/test/test/fu...al/frontend/userActionsTest.php
  8. >> tokens    /home/shin/project/test/test/fu...al/frontend/userActionsTest.php
  9. >> tokens    /home/shin/project/test/apps/fr...user/templates/indexSuccess.php
  10. >> tokens    /home/shin/project/test/apps/fr.../user/actions/actions.class.php

さて、環境が整いました。ここでフォームの入力可能なフィールドを何とするか決めます。パート1との続きということで、メールアドレス、パスワード、名前、生年月日とします。また、パート1と同じくsf_guard_userのusernameは、メールアドレスを入れる項目とします。パート1では、propel:build-allなどで、モデルを作成した際に、ついでにフォームクラスの雛形も生成されます。lib/form/sfGuardUserProfileForm.class.phpがすでにあると思います。そのクラスsfGuardUserProfileFomの空のメソッドconfigureに、フォームで使用するフィールドを選択したり、そのフィールドのラベルを変更したり、バリデターをつけたりしましょう。

つまり、大きく分けて、次の3つを行います。

  1. フォームで使用するフィールドを選択する
  2. ラベルを日本語化する
  3. バリデーションを追加する

フォームで使用するフィールドを選択する

さきほど、どのフィールドを入力可能とするか、決めました。メールアドレス、パスワード、名前、生年月日ですね。では、さっそく、それをセットしましょう。

  1. public function configure()
  2.     {
  3.         $years = range(date('Y') - 60, date('Y') - 17);
  4.         $this->setWidgets(array(
  5.             'username' => new sfWidgetFormInput(),
  6.             'name' => new sfWidgetFormInput(),
  7.             'password' => new sfWidgetFormInputPassword(),
  8.             'birthday' => new sfWidgetFormDate(array('format' => '%year%年%month%月%day%日', 'years' => array_combine($years, $years)))
  9.         ));
  10.     }

誕生日の年の項目は、17歳から60歳の人を対象としてみます。それより若い人、老いた人を対象にしたい場合は、適当に修正してください。

そして、作成したモジュールuserのactions.class.phpにexecuteRegisterメソッドを追加して、Viewファイル、registerSuccess.phpもuserモジュールのtemplates以下に追加しましょう。
actions.class.php

  1. public function executeRegister($request)
  2.     {
  3.         $this->form = new sfGuardUserProfileForm();
  4.     }

registerSuccess.php

  1. <?php $user = $form->getObject() ?>
  2. <form action="<?php echo url_for('user/register') ?>" method="post">
  3.   <table>
  4.     <tfoot>
  5.       <tr>
  6.         <td colspan="2">
  7.           <input type="submit" value="登録する" />
  8.         </td>
  9.       </tr>
  10.     </tfoot>
  11.     <tbody>
  12.       <?php echo $form ?>
  13.     </tbody>
  14.   </table>
  15. </form>

これで、簡単な登録フォームができました。デザイナーの方とフォームのデザインなどを協調的に作業する際には、この$formをこうやって単にechoするのではなく、詳細に書いていくことができるようですが、面倒ですので、ここではしません。

ラベルを日本語化する

さて、パート1のアドミンジェネレータのときと同じく、ラベルがまだ英語になっていますので、日本語に書き換えましょう。sfGuardUserProfileFormクラスのconfigureメソッドの先ほど書いた後あたりに、に次の行を加えて、ラベルをセットします。

  1. $this->widgetSchema->setLabels(array(
  2.             'username' => 'メールアドレス',
  3.             'password' => 'パスワード',
  4.             'name' => '名前',
  5.             'birthday' => '生年月日'
  6.          ));

バリデーションを追加する

これで、フォームができました。次は、バリデーションです。メールアドレスの項目は、必須で、メールアドレスのフォーマットチェック、ユニークチェックをしましょう。パスワードは、必須項目とするだけにします。nameも必須項目とするだけにします。実際に使用する際には、文字数チェックなども、そのアプリの仕様に基づいて変更してください。そして、ラベルをセットした後あたりに、次の行を加えて、バリデーションをセットします。

  1. $this->setValidators(array(
  2.             'username' => new sfValidatorEmail(
  3.                 array(),
  4.                 array('required' => 'メールアドレスの項目は必須です。',
  5.                       'invalid' => 'メールアドレスのフォーマットが間違っています
  6. 。もう一度ご確認ください。')),
  7.             'nickname' => new sfValidatorString(
  8.                 array(),
  9.                 array('required' => 'ニックネームの項目は必須です。')),
  10.             'password' => new sfValidatorString(
  11.                 array(),
  12.                 array('required' => 'パスワードの項目は必須です。')),
  13.             'birthday' => new sfValidatorDate(array('required' => false)),
  14.         ));
  15.         $this->validatorSchema->setPostValidator(new sfValidatorPropelUnique(
  16.             array('model' => 'sfGuardUser', 'column' => array('username')),
  17.             array('invalid' => '指定のメールアドレスは既に登録されています。')
  18.         ));
  19.  
  20.         $this->widgetSchema->setNameFormat('user[%s]');

最後のsetNameFormatは、パラメータのネームスペースのようなものです。フォームの値がuserというキーの連想配列に入るようにしています。つまり、user[password]とか、user[birthday][year]とかになって、サーバにパラメータが送られてくるようになります。

さて、あとは、actionクラスを修正して、ちゃんとバリデーションが通った際には、保存できるようにしましょう。さきほどは、formという変数をレンダーしているだけでしたので、ロジックを書きます。

  1. public function executeRegister($request)
  2.     {
  3.         $this->form = new sfGuardUserProfileForm();
  4.         if ($request->isMethod('post')) {
  5.             $this->form->bind($request->getParameter('user'));
  6.             if ($this->form->isValid()) {
  7.                 $this->form->setIsActive(false);
  8.                 $user = $this->form->save();
  9.                 $email = $user->getEmailaddress();
  10.                 // ここで確認メールを送って、is_activeをtrueにする操作をしたり。
  11.             }
  12.             // validation failed
  13.         }
  14.     }

ところで、

  1. $this-form->setActive(false);

とあるのですが、それは、まだ実装していませんね。なぜこんなことをするかというと、デフォルトのsfGuardUserのis_activeの値は、trueなのです。なので、セットせずに、保存してしまうとここで登録したユーザがそのままログインができるようになってしまうのですね。確認メールを送信するなどして、ちゃんと有効なメールアドレスを登録しているユーザだけを有効にしたい場合が多くあると思いますので、sfGuardUserProfileFormクラスにsetIsActiveメソッドを追加しておきましょう。確認メールの送り方などは、力尽きましたので、ここでは書きません。

というわけで、sfGuardUserProfileFormクラスは最終的に以下のようになりました。

  1. class sfGuardUserProfileForm extends BasesfGuardUserProfileForm
  2. {
  3.     public function configure()
  4.     {
  5.         $years = range(date('Y') - 60, date('Y') - 17);
  6.         $this->setWidgets(array(
  7.             'username' => new sfWidgetFormInput(),
  8.             'name' => new sfWidgetFormInput(),
  9.             'password' => new sfWidgetFormInputPassword(),
  10.             'birthday' => new sfWidgetFormDate(array('format' => '%year%年%month%月%day%日', 'years' => array_combine($years, $years)))
  11.         ));
  12.  
  13.         $this->widgetSchema->setLabels(array(
  14.             'username' => 'メールアドレス',
  15.             'password' => 'パスワード',
  16.             'name' => '名前',
  17.             'birthday' => '生年月日'
  18.         ));
  19.  
  20.         $this->setValidators(array(
  21.             'username' => new sfValidatorEmail(
  22.                 array(),
  23.                 array('required' => 'メールアドレスの項目は必須です。',
  24.                       'invalid' => 'メールアドレスのフォーマットが間違っています。もう一度ご確認ください。')),
  25.             'name' => new sfValidatorString(
  26.                 array(),
  27.                 array('required' => '名前の項目は必須です。')),
  28.             'password' => new sfValidatorString(
  29.                 array(),
  30.                 array('required' => 'パスワードの項目は必須です。')),
  31.             'birthday' => new sfValidatorDate(array('required' => false)),
  32.         ));
  33.  
  34.         $this->validatorSchema->setPostValidator(new sfValidatorPropelUnique(
  35.             array('model' => 'sfGuardUser', 'column' => array('username')),
  36.             array('invalid' => '指定のメールアドレスは既に登録されています。')
  37.         ));
  38.  
  39.         $this->widgetSchema->setNameFormat('user[%s]');
  40.     }
  41.  
  42.     public function setIsActive($value = false)
  43.     {
  44.         $this->object->setIsActive($value);
  45.     }
  46. }

もう一息です。toArrayとfromArrayを上書きする必要があるのを忘れていました。
sfGuardUserProfile.phpにtoArrayとfromArrayを上書きしてみます。

  1. public function fromArray($arr, $keyType = BasePeer::TYPE_PHPNAME)
  2.     {
  3.         parent::fromArray($arr, $keyType);
  4.         $this->setUsername($arr['username']);
  5.         $this->setPassword($arr['password']);
  6.     }
  7.  
  8.     public function toArray($keyType = BasePeer::TYPE_PHPNAME)
  9.     {
  10.         $result = parent::toArray($keyType);
  11.         $result['username'] = $this->getUsername();
  12.         $result['password'] = $this->getPassword();
  13.         return $result;
  14.     }


これで、とりあえず完了です。パート1で使用したアクセサやsaveメソッドは、そのまま使用することで、sf_guard_userとsf_guard_user_profileという二つのテーブルに保存することができるようになります。

ふー。疲れました。

間違いがありましたら、いろいろ教えてください。

July 28, 2008

symfony1.1でsfGuardPluginを使う。パート1

ならべて.comは、symfonyで開発された仕組みですが、1.0を使用しています。先日、ブログに貼り付けることができるウィジェットをリリースしており、もちろん開発は続けているのですが、現在その他に、別の仕組みを開発しており、そこでは、1.1を採用することにしました。symfony自体に関しては、もちろん根本にある使い方は変わっていないのですが、それなりに苦労しましたので、久しぶりにPHPネタでブログを書いてみます。

コードをできるだけ書かないことは、よりシンプルな開発となり、また、それがバグを減らすことなり重要ですよね。そこで、今回の開発では、アドミンジェネレータやプラグインを採用しようと決めました。プラグインでは、symfonyの開発者でもあるFabienさんのsfGuardPluginを使用しました。インストールできない人は、マニュアルを読んでください。

しかし、実際にsymfonyを使用して開発している方に話を聞いたのですが、実際のアプリとして作り込むには、sfGuardPluginは使いにくいので、アプリの作り方として勉強するならいいという話でした。今から書こうとする内容としては、いきなりこんなことを言うなんて凹んでしまいますが、そこは、よりコードを書かないようにゴリ押しで進めてみました。

さて、sfGuardPluginでは、ユーザの持つ情報をsf_guard_userテーブルに保存し、sfGuardUserというモデルで管理しています。このモデルのyamlスキーマは以下のようになっています。

  1. sf_guard_user:
  2.     _attributes:    { phpName: sfGuardUser }
  3.     id:             ~
  4.     username:       { type: varchar, size: 128, required: true, index: unique }
  5.     algorithm:      { type: varchar, size: 128, required: true, default: sha1 }
  6.     salt:           { type: varchar, size: 128, required: true }
  7.     password:       { type: varchar, size: 128, required: true }
  8.     created_at:     ~
  9.     last_login:     { type: timestamp }
  10.     is_active:      { type: boolean, required: true, default: 1 }
  11.     is_super_admin: { type: boolean, required: true, default: 0 }

sfGuardPluginに付いてくるsfGuardUserモジュールによって、sf_guard_userだけのCRUDは可能です。しかし、ユーザの属性には、もっといろいろな情報を持たせて、同時に登録したり、修正したいですよね?例えば、メールアドレスだったり、住所だったり、生年月日だったり、と。こういうときにあるのが、sf_guard_user_profileテーブルです。自分のスキーマファイルにsf_guard_user_profileを好きなカラムで指定することができます。そうすると、アクションクラスの中で$this->user->getProfile()と、持ってくることができます。つまり、テーブルが二つになって、1対1のリレーションで構成を作ってくれます。個人的にはこの1対1のリレーションが嫌いなのですが、プラグインの中を変更することは嫌ですので、このsf_guard_user_profileを使用しようと思います。しかし、やはり1対1のためか、アドミンジェネレータや、1.1から変更のあったフォーム周りを使用しようとすると結構大変でした。というわけで、ゴリ押しです。

さて、今開発しているものをそのまま持ってきてしまうと、説明がややこしくなったり、権利関係で問題になりそうですので、簡略化したモデルを使用しましょう。例えば、次のようなものです。

  1. sf_guard_user_profile:
  2.     _attributes: { phpName: sfGuardUserProfile }
  3.     id:
  4.     user_id:
  5.       type: integer
  6.       required: true
  7.       foreignTable: sf_guard_user
  8.       foreignReference: id
  9.       onDelete: cascade
  10.       onUpdate: cascade
  11.     name:
  12.       type: varchar(64)
  13.       required: true
  14.     birthday:
  15.       type: date
  16.     created_at:
  17.     updated_at:

準備は整いました。というわけで、ようやく本題。今回のsfGuardPluginネタは、二つのポストに分けて書こうと思います。前半のこのポストでは、symfony1.1のsfGuardPluginを、アドミンジェネレータを使用する方法について書きます。後半の次のポスト(予定)では、同様の環境をアドミンジェネレータではなく、symfony1.1から採用された新しいフォームクラスを使用する方法について書きます。

symfonyのアドミンジェネレータは非常に優れていますね。私は初めて使用したときは、単なるCRUDをやってくれるだけなのかな、とバカにしていたのですが、設定ファイルであるgenerator.ymlを編集したり、パーシャルを使用したりすることによって、とても柔軟に仕組みを作ることができるようになっています。しかも、コードをあまり書くことなくに、です。いやぁ、素晴らすぃ。

さて、今回の仕組みの目的は、sf_guard_userでは、格納できる情報が少ないので、sf_guard_user_profileを使用して格納できる情報を増やして、かつ、アドミンジェネレータで一つのモジュールで管理することです。

アドミンジェネレータでは、一つのモデルに対して一つのモジュールを管理する際には、とても有効に使うことができるのですが、複数のモデルを一つのモジュールで編集するには、苦労します。というか、そもそも、複数のモデルを一つのページで編集をさせるという設計がイマイチな感じがしますが、このsf_guard_user_profileを使用する以上は、しょうがないです。ということで、ゴリ押しです。

さて、プロジェクトやアプリケーションを作ったりするのは、ここでは説明しません。それらで躓いている人は、マニュアルを読んでください。もしくは20万円くらいで私が教えます。backendというアプリケーションがすでにあるということで、話を進めていきます。

さきほどのsf_guard_user_profileがある状態で propel:build-allをすると、lib/model/以下にsfGuardUserProfile(Peer|).php等のモデルクラスのファイルができていると思います。それを確認して、アドミンジェネレータを使用してみましょう。確認ですが、実際にアドミンジェネレーターで使用するモデルは、sfGuardUserProfileで、sfGuardUserではありません。

  1. $ ls
  2. apps/   config/  doc/  log/      symfony*  web/
  3. cache/  data/    lib/  plugins/  test/
  4.  
  5. $ ./symfony propel:init-admin backend user sfGuardUserProfile
  6. >> dir+      /home/shin/project/test/apps/backend/modules/user/config
  7. >> file+     /home/shin/project/test/apps/ba...dules/user/config/generator.yml
  8. >> dir+      /home/shin/project/test/apps/backend/modules/user/actions
  9. >> file+     /home/shin/project/test/apps/ba.../user/actions/actions.class.php
  10. >> tokens    /home/shin/project/test/apps/ba...dules/user/config/generator.yml
  11. >> tokens    /home/shin/project/test/apps/ba.../user/actions/actions.class.php

とすると、userモジュールが生成されて、デフォルトのCRUDができるようになりますね。画面のキャプチャを取ろうと思いましたが、面倒なので、想像で補ってください。生成されたgenerator.ymlは、次のようになっていますね。

  1. generator:
  2.   class:              sfPropelAdminGenerator
  3.   param:
  4.     model_class:      sfGuardUserProfile
  5.     theme:            default

そして、このgenerator.ymlをどんどん編集していきましょう。編集の仕方は、この辺を参照してください。

さて、アドミンジェネレータで生成された編集画面ですが、Userやら、Nameやら、Birthdayやら、Created atやら、Updated atの項目がありますが、このままでは、使えないです。というわけで、次のことをするとしましょう。

  1. ラベルを日本語化する
  2. created_at, updated_at, user_idとかがいらないので消す
  3. その代わり、sf_guard_userのusername, passowrd, is_activeを編集できるようする

ラベルを日本語化する

  1. generator:
  2.   class:              sfPropelAdminGenerator
  3.   param:
  4.     model_class:      sfGuardUserProfile
  5.     theme:            default
  6.  
  7.     fields:
  8.       name: { name: 名前 }
  9.       birthday: { name: 生年月日 }

fieldsの項目が増えただけです。編集可能なフィールドは、nameとbirthdayだけにしましょう。というわけで、その二つだけラベルをセットしました。

created_at, updated_at, user_idとかいらないので消す

  1. generator:
  2.   class:              sfPropelAdminGenerator
  3.   param:
  4.     model_class:      sfGuardUserProfile
  5.     theme:            default
  6.  
  7.     fields:
  8.       name: { name: 名前 }
  9.       birthday: { name: 生年月日 }
  10.  
  11.     list:
  12.       title: ユーザ一覧
  13.       display:
  14.         [ id, name ]
  15.       object_actions:
  16.         _edit: -
  17.         _delete: -
  18.  
  19.     edit:
  20.       title: ユーザ編集
  21.       display:
  22.         "基本情報": [ name ]
  23.         "詳細情報": [ birthday ]

とりあえず、sfGuardUserProfileで編集可能なフィールドは、nameとbirthdayだけですので、シンプルですね。

sf_guard_userのusername, passowrd, is_activeを編集できるようする

ユーザの情報では、usernameとpasswordを編集したいですよね?ついでに、is_activeも編集できるようにしましょう。つまり、有効ユーザか否かという項目です。さらにusernameは、メールアドレスの格納場所としましょう。本当はメールアドレスはemailとかmail_address等のフィールドとしたいところですが、しょうがないので、usernameをメールアドレスとして扱います。

まず、generator.ymlを修正してみます。

  1. generator:
  2.   class:              sfPropelAdminGenerator
  3.   param:
  4.     model_class:      sfGuardUserProfile
  5.     theme:            default
  6.  
  7.     fields:
  8.       email_address: { name: メールアドレス }
  9.       password: { name: パスワード }
  10.       name: { name: 名前 }
  11.       birthday: { name: 生年月日 }
  12.       is_active: { name: 有効ユーザ }
  13.  
  14.     list:
  15.       title: ユーザ一覧
  16.       display:
  17.         [ id, email_address, name, is_active ]
  18.       object_actions:
  19.         _edit: -
  20.         _delete: -
  21.  
  22.     edit:
  23.       title: ユーザ編集
  24.       display:
  25.