Tag Archives: symfony

Storing User Changes in a Symfony/Doctrine App

Admins on soccer league management site manyleagues.com need to be able to see who made which changes when. Those records are stored in a MySQL table and can be pulled up in a report, filterable by date, change type and so forth.

This post is about how to best capture the changes when a record is updated. Let’s use Game as an example, and suppose the location has been changed. We need a record that looks like this:
ChangeLogScreenshot

We have a listener to Doctrine’s onFlush event defined:

on_flush_listener:
    class: ManyLeagues\SoccerBundle\EventListener\OnFlushListener
    tags:
        - {name: doctrine.event_listener, event: onFlush}
namespace ManyLeagues\SoccerBundle\EventListener;

use Doctrine\ORM\Event\OnFlushEventArgs;

use ManyLeagues\SoccerBundle\Helper\ChangeLogHelper;

class OnFlushListener
{
    public function onFlush(OnFlushEventArgs $args)
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if (method_exists($entity, 'hasChangeLog') && $entity->hasChangeLog())
            {
                $changeset = $uow->getEntityChangeSet($entity);
                ChangeLogHelper::updatedEntity($entity, $changeset);
            }
        }
    }
}

Note that we test to see if the entity is one that we want to save. Not every class needs to have changes stored.

The ChangeLogHelper will get the description from the model and other parameters from the session:

public static function updatedEntity($entity, $changeset)
{
    ...
    $description = $entity->getChangesetDescription($changeset);
    self::recordChange('update', $entity, $description, $user_id, $ip, $site_id);
}

Entities share a trait that handles the descriptions:

public function getChangesetDescription($changeset)
{
    $description = [];
    foreach ($changeset as $field => $change)
    {
        $field_change_description = $this->getFieldChangeDescription($field, $change);
        if ($field_change_description)
        {
            $description[] = $field_change_description;
        }
    }
    return implode(';', $description);
}

getFieldChangeDescription is in the trait, but many entities override the method. In it’s basic form:

public function getFieldChangeDescription($field, $change)
{
    $ignore_fields = ['updatedAt'];
    if (!in_array($field, $ignore_fields))
    {
        $field_display = $this->getFieldDisplay($field);
        if ($field_display)
            return $this->getChangeDescriptionSentence($field_display, $change);
    }
    return NULL;
}

The two methods called here are easily customized in the model. In the trait, we have:

public function getChangeDescriptionSentence($field_display, $change)
{
    $change = $this->getFieldChangeDescriptionValues($field_display, $change);
    $old_val = $change[0];
    $new_val = $change[1];
    if ($old_val === $new_val)
    {
        return NULL;
    }
    return $field_display . ' changed from ' . $old_val . ' to ' . $new_val;
}

getFieldChangeDescriptionValues handles date formatting and empty values.

The main thing to keep in mind, Doctrine’s UnitOfWork has to be dealt with very precisely. Other Doctrine activity between the time of persistence and flushing can cause the changeset to be miscalculated, which not only interferes with the changelog records, but with the entity itself. For that reason ChangeLogHelper::recordChange creates a Redis hash with the necessary information, which will be queued for insertion into the changelog table.

Adding A Client-Customizable Reports Page In A Symfony Application

Recently I decided to port ManyLeagues.com, a sports league management site, to Symfony2. One nice feature in ManyLeagues is the Report View, in which the client can easily see information concerning registration, payments, disciplinary actions, etc.

reports page

manyleagues.com reports page

The page uses jQueryUI tabs each of which should be shown or hidden based on the user’s role.

There are various administrative roles, such as regular admin, site admin, finance admin, referee assignor and super admin. A regular admin is mostly concerned with handling registration, receiving money, and so on. A finance admin would be responsible for maintaining the Paypal API credentials, setting refund policies and paying ManyLeagues. A super admin can do anything.

Not all reports are shown to all admins. For example, only someone with the role of finance admin or above should be shown the Paypal report tab. Furthermore, the reports in the system should be easy to add to, as new requests arrive regularly. A config file is the perfect location.

show_reports: []
reports:
  personRegistration: 
    name: Person Registration
    access: admin
  teamRegistration: 
    name: Team Registration
    access: admin
  referees: 
    name: Referees
    access: admin
  paypal: 
    name: Paypal
    access: finance_admin
    requirement: paypal

The client config gets loaded from the database upon user login, since there are several leagues using the site, which overwrites some of the information from the config file. The key show_reports is used to determine which reports a client has on. The requirement key refers to another config setting, in this case a boolean key called paypal. If true, the site allows users to pay via Paypal. If false, there is no reason to show the Paypal report tab to anyone.

The super admin can edit which reports are shown. In the controller:

public function reportsConfigAction()
{
  $reports = $this->get('config')->get('reports');
  $choices = [];

  foreach ($reports as $report_key => $report)
  {
      $choices[$report_key] = $report['name'];
  }

  $default_data = ['show_reports' => $this->get('config')->get('show_reports')];

  $form = $this->createFormBuilder($default_data)
    ->add('show_reports', 'choice', [
      'choices' => $choices, 
      'multiple' => TRUE, 
      'expanded' => TRUE, 
      'label' => 'Reports to Show:'
    ])
    ->add('submit', 'submit')
    ->setAction($this->generateUrl('site_update'))
    ->setMethod('POST')
    ->getForm();

  return $this->render('TheBundle:Site:reports_config.html.twig', [
      'form' => $form->createView()
  ]);
}

Upon form submission, the config will now have something like

  show_reports["personRegistration","referees","paypal"]

When an admin user accesses the reports view, here is the controller action:

  $access_granted = [];
  if ($this->get('security.context')->isGranted('ROLE_ADMIN'))
  {
      $access_granted[] = 'admin';
  }
  if ($this->get('security.context')->isGranted('ROLE_FINANCIAL_ADMIN'))
  {
      $access_granted[] = 'finance_admin';
  }
  $special = ['paypal' => $this->get('config')->get('paypal')];

  return $this->render('TheBundle:Report:reports.html.twig', [
      'access_granted' => $access_granted,
      'reports' => $this->get('config')->get('reports'),
      'show_reports' => $this->get('config')->get('show_reports'),
      'special' => $special
  ]);

Here is the relevant part of the template:

{% for report_key, report in reports %}
  {% if report_key in show_reports and report.access in access_granted %}
    {% if 'requirement' not in report|keys or special[report.requirement] %}
      <li><a href="#data_tab" data-name="{{ report_key }}">{{ report.name }}</a></li>
    {% endif %}
  {% endif %}
{% endfor %}

This is an overview only, but hopefully enough to give you a good idea how to implement this for your site.