Showing posts with label bundle. Show all posts
Showing posts with label bundle. Show all posts

Friday, October 30, 2015

Symfony Classifieds Bundle - define requirements and database structure

 As settled in the previous post I will be defining the business requirements and based on those  create the database structure. Below is the current list of features I want to have in my app:

- anyone can see the ads (no login required)
- only REGISTERED USER can post ads
- minimum information about user: EMAIL, PASSWORD, PHONE, NAME, LASTNAME
- an user after login can manage his ads: see ADS LIST, EDIT(includes reactivate EXPIRED AD), DELETE buttons
- an USER can can  edit/modify/delete an AD after LOGIN
- an ad can be normal (free) or promoted. an ad is promoted for a period of time - after this time it becomes normal ad and it will expire as any ad.
- in the same page where you add/edit an ad you  can enter the code of your payment or promotional code which makes the ad to be upgraded to PROMOTED
- doing the payment is not in the scope of this project, could be an extension
- the ADMIN USER creates CATEGORIES and subcategories, USERS can post ads only in existing categories.
- a CATEGORY can be one of the main categories (no parent) or it can have a PARENT CATEGORY, in this case it is a subcategory.
- an unlimited number of levels of subcategories can be added by ADMIN
- an AD can be posted in just ONE CATEGORY
- an AD cannot be posted directly on a CATEGORY/SUBCATEGORY which has at least a SUBCATEGORY
- an AD EXPIRES after a certain period of time (it will not be displayed but still exist in the database).
- an EXPIRED AD can be REACTIVATED
- limit the number of ADS an USER can post in one day ( 30 should be enough).
- an AD contains: TITLE, CATEGORY, USER NAME (owner), USERs contact method (email/phone), PRICE, DESCRIPTION,
TAGS, PHOTOS, STATUS (active/expired) DATE when posted, calculated expiration date (not displayed), SPECIFIC ITEMS per CATEGORY defined by ADMIN
- a CATEGORY can have SPECIFIC ITEMS (fields) which will be inherited in any child SUBCATEGORY
(example: CAR category has items like Color, it will be present in sub-categories like "New Cars" and "Used Cars")
- ADMIN sets maximum number of PHOTOS and maximum PHOTO size. Application should come with default values.
- SEARCH feature directly in all records without selecting information like category/subcategory
- search by LOCATION
- ORDER BY function, with parameters: PRICE LOW, PRICE HIGH, NEWEST FIRST
- detailed SEARCH: CATEGORY, SUBCATEGORY (is any), price minimum, price maximum,CURRENCY
- ADMIN sets a BASE CURRENCY and any other accepted CURRENCIES, ADMIN sets conversion rate between BASE CURRENCY and the others
- have a MOST RECENT ADS view
- AD LOCATION country/region/city/city_area


For drawing the data model diagram I am using www.draw.io It very easy to use, you can save your work  directly on cloud or on your local computer.



I wrote the SQL for creating these tables in my MySQL installation :
                              Server version: 5.6.15-log - MySQL Community Server (GPL) )

#Category table, subcategories are categories with parent. Root categories have parent_id = NULL
CREATE TABLE category(
   id_category INT AUTO_INCREMENT PRIMARY KEY,
   category_name VARCHAR(50) NOT NULL,
   parent_id INT NULL,
   CONSTRAINT fk_parent_id_category
   FOREIGN KEY fk_parent_id (parent_id)
   REFERENCES category(id_category)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


#SpecificItemsTypes table - specific items types are for example items type related to cars: year of production, no of kilometres, ABS,
#Or about an apartment: surface, year of construction, etc.
CREATE TABLE specific_items_types(
   id_specific_item_type INT  AUTO_INCREMENT PRIMARY KEY,
   item_type_name VARCHAR(50)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#SpecificItemsTypes_meta  -  meta table between "category" and "specific_items_types"
CREATE TABLE specific_items_types_meta(
    id_specific_meta INT AUTO_INCREMENT PRIMARY KEY,
    id_specific_item_type INT NOT NULL,
    id_category INT NOT NULL,
    CONSTRAINT fk_id_category
    FOREIGN KEY fk_id_category (id_category)
    REFERENCES category(id_category),
    CONSTRAINT fk_id_specific_item_type
    FOREIGN KEY fk_id_specific_item_type (id_specific_item_type)
    REFERENCES specific_items_types(id_specific_item_type)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#SpecificItems  -  holds the values for the actual items. for example specific item "surface" will have FK id 2 and value yes.
CREATE TABLE specific_items(
    id_specific_item INT AUTO_INCREMENT PRIMARY KEY,
    id_specific_item_type INT NOT NULL,
    id_ads INT UNSIGNED NOT NULL,
    item_value VARCHAR(50) NOT NULL,
    CONSTRAINT fk_id_ads
    FOREIGN KEY fk_id_ads (id_ads)
    REFERENCES ads(id_ads),
    CONSTRAINT fk_id_specific_item_types
    FOREIGN KEY fk_id_specific_item_type (id_specific_item_type)
    REFERENCES specific_items_types(id_specific_item_type)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


#Currencies table
CREATE TABLE currencies(
    id_currency INT AUTO_INCREMENT PRIMARY KEY,
    currency_name VARCHAR(30) NOT NULL,
    base_currency TINYINT(1) NOT NULL,
    convertion_rate FLOAT(10,2) NOT NULL,
    enabled TINYINT(1) NOT NULL
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#Ads
CREATE TABLE ads(
    id_ads INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    id_user INT NOT NULL,
    id_city_area INT NOT NULL,
    id_category INT NOT NULL,
    id_currency INT NOT NULL,
    title VARCHAR(100) NOT NULL,
    description VARCHAR(500) NOT NULL,
    price BIGINT NOT NULL,
    email VARCHAR(255) NOT NULL,
    phone_number VARCHAR(20),
    path_photos VARCHAR(1024),
    created_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,
    spam  TINYINT(1) NOT NULL,
    CONSTRAINT fk_id_user
    FOREIGN KEY fk_id_user (id_user)
    REFERENCES fos_user(id),
    CONSTRAINT fk_id_city_area
    FOREIGN KEY fk_id_city_area (id_city_area)
    REFERENCES city_areas(id_city_area),
    CONSTRAINT fk_id_currency
    FOREIGN KEY fk_id_currency(id_currency)
    REFERENCES currencies(id_currency)   ,
    CONSTRAINT fk_id_categories
    FOREIGN KEY fk_id_categories(id_category)
    REFERENCES category(id_category) 

)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


#PromotedAsPayment - under construction!!
CREATE TABLE promoted_ads_payment(
   id_promoted_ads_payment INT AUTO_INCREMENT PRIMARY KEY,
   id_ads INT UNSIGNED NOT NULL,
   payment_code VARCHAR(50) NOT NULL,
   payment_date DATETIME NOT NULL,
   expiration_date_promo DATETIME NOT NULL,
   CONSTRAINT fk_id_ads_promo
   FOREIGN KEY fk_id_ads (id_ads)
   REFERENCES ads(id_ads)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;



#Countries
CREATE TABLE countries(
    id_country INT AUTO_INCREMENT PRIMARY KEY,
    country_code VARCHAR(2) NOT NULL,
    country_name VARCHAR(50) NOT NULL
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#Regions
CREATE TABLE regions(
    id_region INT AUTO_INCREMENT PRIMARY KEY,
    id_country INT NOT NULL,
    region_name VARCHAR(50) NOT NULL,
    CONSTRAINT fk_id_country
    FOREIGN KEY fk_id_country (id_country)
    REFERENCES countries(id_country)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#Cities
CREATE TABLE cities(
    id_city INT AUTO_INCREMENT PRIMARY KEY,
    id_region INT NOT NULL,
    city_name VARCHAR(50) NOT NULL,
    CONSTRAINT fk_id_region
    FOREIGN KEY fk_id_region (id_region)
    REFERENCES regions(id_region)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

#City Areas
CREATE TABLE city_areas(
    id_city_area INT AUTO_INCREMENT PRIMARY KEY,
    id_city INT NOT NULL,
    city_area_name VARCHAR(50) NOT NULL,
    CONSTRAINT fk_id_city
    FOREIGN KEY fk_id_city (id_city)
    REFERENCES cities(id_city)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
----------------------------
----------------------------
I also inserted some data to some tests for now, (I will create some data fixtures later).
Please see below sources from where I taken countries list, list of regions in France, list of cities in France (some editing is needed before putting those in my tables)
For the other fields(ads, currencies, catergories)  I used my imagination

https://github.com/raramuridesign/mysql-country-list/blob/master/mysql-country-list.sql
https://github.com/pixelastic/sql-french-cities/blob/master/regions.sql
https://raw.githubusercontent.com/pixelastic/sql-french-cities/master/cities.sql

-------------------------------------
-------------------------------------

Some queries to test my data model:

#Select category name and specific items allocated to it
SELECT   c.category_name
        ,i.item_type_name
from  category as c
    INNER JOIN specific_items_types_meta as meta
        ON meta.id_category=c.id_category
    INNER JOIN specific_items_types as i
        ON meta.id_specific_item_type = i.id_specific_item_type
WHERE c.category_name="cars";

#SELECT a complete AD from all tables related (without specific items)
SELECT    a.title
        , a.description
        , a.price
        , c.currency_name
        , a.phone_number
        , ca.city_area_name
        , a.expires_at
        , u.name
FROM ads AS a
    INNER JOIN currencies AS c
        ON c.id_currency=a.id_currency
       
    INNER JOIN fos_user AS u
        ON a.id_user=u.id
       
    INNER JOIN city_areas AS ca
        ON a.id_city_area  = ca.id_city_area
  
WHERE a.id_ads=1
----------------------------------------------------

#SELECT specific_items for an AD
SELECT    a.id_ads
        , a.title
        , sit.item_type_name
        , si.item_value
FROM specific_items AS si
    INNER JOIN ads AS a
        ON a.id_ads = si.id_ads
    INNER JOIN specific_items_types AS sit
        ON si.id_specific_item_type = sit.id_specific_item_type

WHERE a.id_ads=1;

Friday, September 25, 2015

cpana/basicblogbundle now on GitHub and Packagist

https://github.com/cristianpana86/BasicBlogBundle
https://packagist.org/packages/cpana/basicblogbundle
I've worked previously with GitHub, the new thing was to have the package on Packagist.com so it can be installed easily via Composer.
After creating my repository on GitHub I went on Packagist and created an account.
Following the instructions found on this article: http://www.sitepoint.com/listing-packages-on-packagist-for-composer/ I've created my composer.json document:

{
    "name":        "cpana/basicblogbundle",
    "type":        "symfony-bundle",
    "description": "Symfony Basic Blog Bundle",
    "keywords":    ["blog","symfony"],
    "homepage":    "https://github.com/cristianpana86/BasicBlogBundle",
    "license":     "MIT",
    "authors": [
        {
            "name": "Cristian Pana",
            "email": "cristianpana86@yahoo.com"
        }
   ],
   "require": {
       "php": ">=5.5.0",
       "symfony/symfony": "2.7.*",
        "doctrine/orm": "~2.2,>=2.2.3,<2.5",
        "doctrine/dbal": "<2.5",
        "doctrine/doctrine-bundle": "~1.4",
        "symfony/assetic-bundle": "~2.3",
        "symfony/swiftmailer-bundle": "~2.3",
        "symfony/monolog-bundle": "~2.4",
        "sensio/distribution-bundle": "~4.0",
        "sensio/framework-extra-bundle": "~3.0,>=3.0.2"
    },
    "minimum-stability": "dev",
    "autoload" : {
        "psr-4" : {
            "CPANA\\BasicBlogBundle\\" : ""
        }
    }
}

Some of the values are from the Symblog composer.json and may be outdated, it's something I have to look over.
I've added also the installation instructions on my GitHub repository and from there are automatically listed on Packagist.com:

Install using Composer:
    composer require cpana/basicblogbundle:dev-master
 
Register the bundle in AppKernel.php by adding:
    new CPANA\BasicBlogBundle\CPANABasicBlogBundle(),
 
Import paths in app/config/routing.yml by adding:
    CPANABasicBlogBundle:
    resource: "@CPANABasicBlogBundle/Resources/config/routing.yml"
 
Make sure to have configured your database in app/config/parameters.yml Generate you schema using console:
    php app/console cache:clear
    php app/console doctrine:schema:update --force

Thursday, September 24, 2015

CPANABasicBlogBundle for Symfony2

Having as a starter point the http://tutorial.symblog.co.uk/ ( about which I wrote here ) I developed blog bundle having the following main features:

Frontside
- view all blog posts with pagination
- view individual blog posts
- add comments

Admin
- view a list of all blog posts with Edit and Delete options.
- view the list of comments with options to Approve/Unapprove or Delete.

Adding comments form


In the Symblog tutorial there was a add comment functionality but for me didn't worked throwing an error like "Catchable Fatal Error: Object of class XYZ could not be converted to string in Doctrine\DBAL\Statement.php" ( see this post about it ). To solve the issue I've added to Entity\Blog.php the following function:

    public function __toString()
    {
        return strval($this->id);
    }

Also I modified the CommentType.php to make all the comments unapproved initially:

// \src\CPANA\BasicBlogBundle\Form\CommentType.php

public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('user')
            ->add('comment')
            ->add('approved','hidden', array('data' => '0',))
         ;
    }

Uploading photos

What I want to implement: upload photo, copy the file in a specific path  and save the name of the file on the database

Make sure you are pointing to the right path in the template. In src\CPANA\BasicBlogBundle\Resources\views\Blog\show.html.twig:

<img src="{{ asset(['bundles/basicblogbundle/images/', blog.image]|join) }}" alt="{{ blog.title }} image not found"  />

At this path: 'bundles/basicblogbundle/images/' I should upload the files in the controller.

Make sure to indicate that the "file" field is not mapped to the Entity.

->add('image', 'file', array('mapped'=>false))

Upload photo and save in database only the name
------------------------------------------------
if ($form->isValid()) {
            $newFilename = $form['attachment']->getData()->getClientOriginalName();
            // Handle picture upload process
            $uploadDir=dirname($this->container->getParameter('kernel.root_dir')) . '/web/bundles/basicblogbundle/images/';
            $form['image']->getData()->move($uploadDir,$newFilename);
            // End of upload
           
            $blog->setImage($newFilename);
            $em = $this->getDoctrine()->getManager();
            $em->persist($blog);
            $em->flush();


This works fine but we should make sure the file names are unique so they do not conflict when you try to upload a file with same name.

We should add random string function in a class. I do no think this should be considered a service, so I will not register it as a service in the Dependency Injection container.
http://symfony.com/doc/current/best_practices/business-logic.html

Create an Utils folder. Place there RandomString.php, add a static function: randomStr

namespace CPANA\BasicBlogBundle\Utils;

class RandomString
{

    public static function randomStr($length = 10)
    {
        $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
        $charactersLength = strlen($characters);
        $randomString = '';
        for ($i = 0; $i < $length; $i++) {
            $randomString .= $characters[rand(0, $charactersLength - 1)];
        }
        return $randomString;
   
    }



After this include the randomStr in the fileName:

$newFilename =RandomString::randomStr() . $form['image']->getData()->getClientOriginalName();
---------------------------
when testing the new feature I noticed that the photo is not available when browsing the blog, reason: the name included in the template is trimmed. I looked in the database , in the "image" column, same there. Checking the type of column I noticed the column is set to     varchar(20)!!!! that's the problem right there.

Solution:
modify in Entity\Blog.php the ORM annotation for $image from
@ORM\Column(type="string", length=20)
to
@ORM\Column(type="string", length=255)

After this update the DB schema using Symfony 2 command line:

php app/console cache:clear
php app/console doctrine:schema:update --force

 Pagination

In the home view of the blog I should list all the blog posts. I want to limit to 3 per page an have buttons to navigate though posts.

The path should have a parameter "currentPage" which can be optional, and the default value is 1:

CPANABasicBlogBundle_homepage:
    pattern:  /blog/{currentPage}
    defaults: { _controller: CPANABasicBlogBundle:Blog:blogHome, currentPage: 1 }
    requirements:
        _method:  GET
        currentPage: \d+


--------------
Modify the repository as seen here - http://anil.io/post/41/symfony-2-and-doctrine-pagination-with-twig:

        public function getAllPosts($currentPage = 1)
        {
            // Create our query
            $query = $this->createQueryBuilder('p')
                ->orderBy('p.created', 'DESC')
                ->getQuery();

            $paginator = $this->paginate($query, $currentPage);

            return $paginator;
        }


        public function paginate($dql, $page = 1, $limit = 3)
        {
            $paginator = new Paginator($dql);
           
            $paginator->getQuery()
                ->setFirstResult($limit * ($page - 1)) // Offset
                ->setMaxResults($limit); // Limit

            return $paginator;
        }

----------------------------------------------------
In controller retrieve posts and pass them to view

    public function blogHomeAction($currentPage=1)
    {
        $em = $this->getDoctrine()
            ->getEntityManager();

        $posts = $em->getRepository('CPANABasicBlogBundle:Blog')
            ->getAllPosts($currentPage);
       
        $iterator=$posts->getIterator();
        $limit = 3;
        $maxPages = ceil($posts->count()/$limit);
        $thisPage = $currentPage;
       
        return $this->render(
            'CPANABasicBlogBundle:Blog:home.html.twig', array(
            'blogs' => $iterator,
            'maxPages'=>$maxPages,
            'thisPage' => $thisPage,
            )
        );


In template showing the posts is the same we just need to add pagination buttons:
This will look nice with some css :)

{% if maxPages > 1 %}
    <ul>
        {%if thisPage > 1 %}
        <li >
                <a href="{{ path('CPANABasicBlogBundle_homepage', {currentPage: thisPage-1 < 1 ? 1 : thisPage-1}) }}">«</a>
        </li>
        {% endif %}
       
        {# Render each page number #}
        {% for i in 1..maxPages %}
        <li>
            <a href="{{ path('CPANABasicBlogBundle_homepage', {currentPage: i}) }}">{{ i }}</a>
        </li>
        {% endfor %}

        {# `»` arrow #}
        {%if thisPage < maxPages %}
        <li>
            <a href="{{ path('CPANABasicBlogBundle_homepage', {currentPage: thisPage+1 <= maxPages ? thisPage+1 : thisPage}) }}">»</a>
        </li>
        {% endif %}
    </ul>
    {% endif %}


The photo should not be a mandatory information. I had to do the following modifications to implement that:

Add in controller to the form builder 'required' => false :
 ->add('image', 'file', array('mapped'=>false,'required' => false,))

Also in controller handle new uploaded file name and path only if it was any file selected in the form:

if (!is_null($form['image']->getData())) {....}
 

modify the Entity/Blog.php to accept NULL values for image field.

 * @ORM\Column(type="string", length=255, nullable=true)
Update database using console command.
        php app/console cache:clear
        php app/console doctrine:schema:update --force

Fine tuning - allow deleting blog posts even if they have comments

While browsing happily and testing the functionality I discovered I cannot delete an article having comments, receiving some nasty error: "SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails ..."

I believe it should be good that an admin to be able to delete an article even if it has comments added to it. So I will modify the Database structure to allow this behavior.

There are two kinds of cascades in Doctrine:

1) ORM level - uses cascade={"remove"} in the association - this is a calculation that is done in the UnitOfWork and does not affect the database structure. When you remove an object, the UnitOfWork will iterate over all objects in the association and remove them.

2) Database level - uses onDelete="CASCADE" on the association's joinColumn - this will add On Delete Cascade to the foreign key column in the database:

@ORM\JoinColumn(name="father_id", referencedColumnName="id", onDelete="CASCADE")


Update the Entity/Comment.php
    /**
     * @ORM\ManyToOne(targetEntity="Blog", inversedBy="comments")
     * @ORM\JoinColumn(name="blog_id", referencedColumnName="id",  onDelete="CASCADE")
     */
    protected $blog;


Update the database using console:
    php app/console cache:clear
    php app/console doctrine:schema:update --force


I will soon add the BasicBlogBundle on Github and Packagist.

Tuesday, September 8, 2015

Build a blog bundle with Symfony 2 - Symblog tutorial errors/deprecated functions

Symblog tutorial ( http://tutorial.symblog.co.uk/ ) is one of the best I found about developing and testing a Symfony 2 bundle.
But is not all perfect, some of the function used in the tutorial are deprecated and will not work.
I found another blog were some of the issues are solved:
 http://donna-oberes.blogspot.com/2013/09/symfony-2-symblog-tutorial-errors.html

Please see below a list comments that you help you finish the tutorial successfully.
I hope this blog post will help some fellows lost  in the path of learning Symfony 2.

1. In controller repalce "bindRequest()" deprecated function with "bind()"

public function contactAction()
    {
        $enquiry = new Enquiry();
        $form = $this->createForm(new EnquiryType(), $enquiry);
       
        $request = $this->getRequest();
        if ($request->getMethod() == 'POST') {
            $form->bind($request);


2. I stoled this from a comment on the Symblog website:

FYI for symfony2.3 (and 2.4) users:

{% if app.session.hasFlash('blogger-notice') %}
<div class="blogger-notice">
{{ app.session.flash('blogger-notice') }}
</div>
{% endif %}


needs to be changed to

{% for flashMessage in app.session.flashbag.get('blogger-notice') %}
<div class="blogger-notice">
{{ flashMessage }}
</div>
{% endfor %}


Also you need to change the “setFlash” in PageController to call  “getFlashBag()->add”


3. Doctrine basic usage in official documentation:

http://symfony.com/doc/current/book/doctrine.html

4. In  BlogController:

DEPRECATED - getEntityManager is deprecated since Symfony 2.1. Use getManager instead

Change path to images in show.htmltwig. it should point to specific bundle folder in the public "web" folder:

 <img src="{{ asset(['bundles/bloggerblog/images/', blog.image]|join) }}


5. Doctrine migrations

http://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html

$ composer require doctrine/doctrine-migrations-bundle "^1.0"

In AppKernel.php you need to change this

new Symfony\Bundle\DoctrineMigrationsBundle\DoctrineMigrationsBundle() //old path

with this

new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle()


----------------------------------------------------------------------------------------------------------
Some errors messages that may make you land on this page:

FatalErrorException: Error: Call to undefined method Symfony\Component\Form\Form::bindRequest() in /path/to/root/src/Blogger/BlogBundle/Controller/PageController.php 
FatalErrorException: Error: Class 'Symfony\Component\Validator\Constraints\MaxLength' not found in /path/to/root/src/Blogger/BlogBundle/Entity/Enquiry.php