I am building an entity listener in an application based on symfony 2.7. It will apply a tag to a video whenever the video's owning group changes.
My listener looks like this so far:
public function preUpdate($video, $args)
{
$changeSet = $args->getEntityChangeSet();
if(!array_key_exists('ownerGroup', $changeSet )){
return;
}
$oldGroupObj = $changeSet['ownerGroup'][0];
$oldGroupName = $oldGroupObj->getName();
//die($oldGroupName); //Gives us a valid group name string.
$tagRepository = $args->getEntityManager()->getRepository('AppBundle:Tag');
$tag = $tagRepository->findOneBy( ['title' => 'migrated' . $oldGroupName] );
if( $tag === null ){
$tag = new Tag;
$tag->setTitle('migrated' . $oldGroupName);
}
$video->addTag($tag);
}
The problem is that last line. When I run it, it causes this exception:
The given model has already started the "default_workflow" process.
What does this exception mean, and how can I save the new tag to my video when the owning group changes?
Trully, I have not develop application with symfony. But after read some documentation symfony, for your case exception The given model has already started the "default_workflow" process, interested at "Process Component", focused at Running Function (mustRun()), except that it will throw a ProcessFailedException if the process couldn't be executed successfully.
So, Globally, do not just focussed at your "function preUpdate", but all-of-your-big-code-symfony, because that exception "open from" The Process-Of-Your-Application
Related
Using Google API v3 I try to move a file from one folder to another. I am using a wrapper class in Laravel, the file and parent IDs are correct. Developing from the documentation, I have tried code as:
public function moveFileFromTo($fileID, $toParentID) {
$fromFile = $this->service->files->get($fileID, ['fields' => '*']);
$fromParentID = $fromFile->getParents();
$blankFile = new Google_Service_Drive_DriveFile();
$this->service->files->update($fileID, $blankFile, [
"removeParents" => $fromParentID,
"addParents" => $toParentID
]);
}
However, this seems to move the file but strips out all the meta data.
I have also tried
public function moveFileFromTo($fileID, $toParentID) {
$fromFile = $this->service->files->get($fileID, ['fields' => '*']);
$fromParentID = $fromFile->getParents();
$fromFile->setParents($fromParentID);
$this->service->files->update($fileID, $fromFile);
}
However, this gives the error:
Google\Service\Exception
{ "error": { "errors": [ { "domain": "global", "reason":
"fieldNotWritable", "message": "The resource body includes fields
which are not directly writable." } ], "code": 403, "message": "The
resource body includes fields which are not directly writable." } }
I wish to simply move the file and retain all its metadata. From the documentation, it seems either a new empty file is required in update (really weird) or I must somehow strip out the fields of the object used in the second argument ($fromFile) it does not like to be written to (even though I am simply updating the files parents - which is writable).
See also https://issuetracker.google.com/issues/199921300
Problems with Answers so far:
but grateful for responses
$fromFile = $this->service->files->get($fileID, ['fields' => 'parents, id']);
returns all ~75 attributes a lot of which are not writeable.
Instead of the expected 2 as per PHPStorm debug (note the break is at the statement immediately following the GET request so irrelevant at this point
using
unset($fromFile->shared);
still leaves other writable attributes
and indeed the file is not actually shared
UPDATE TO MY CODING
public function moveFileFromTo($fileID, $toParentID) {
$fromFile = $this->service->files->get($fileID, ["fields" => "id,parents"]);
$fromFile = $this->getParsedWritableFile($fromFile);
$fromFile->setParents($toParentID);
$this->service->files->update($fileID, $fromFile, ['addParents' => $toParentID]);
}
getParsedWritableFile is trying to just set writable attributes on a new Google Drive file object:
public function getParsedWritableFile($gdrivefile) {
$gdrivefile = new \Google_Service_Drive_DriveFile();//comment or delete, just here to check auto complete function names
$parsedFile = new \Google_Service_Drive_DriveFile();
//$parsedFile=$gdrivefile;
// currently only allow these according to https://developers.google.com/drive/api/v3/reference/files#resource-representations
$parsedFile->setName($gdrivefile->getName());//name
$parsedFile->setMimeType($gdrivefile->getMimeType());//mimeType
$parsedFile->setDescription($gdrivefile->getDescription());//description
$parsedFile->setStarred($gdrivefile->getStarred());//starred
$parsedFile->setTrashed($gdrivefile->getTrashed());//trashed
$parsedFile->setParents($gdrivefile->getParents());//parents
$parsedFile->setProperties($gdrivefile->getProperties());//properties [object]
$parsedFile->setAppProperties($gdrivefile->getAppProperties());//appProperties [object]
$parsedFile->setCreatedTime($gdrivefile->getCreatedTime());//createdTime
$parsedFile->setModifiedTime($gdrivefile->getModifiedTime());//modifiedTime
$parsedFile->setWritersCanShare($gdrivefile->getWritersCanShare());//writersCanShare
$parsedFile->setViewedByMeTime($gdrivefile->getViewedByMeTime());//viewedByMeTime
$parsedFile->setFolderColorRgb($gdrivefile->getFolderColorRgb());//folderColorRgb
$parsedFile->setOriginalFilename($gdrivefile->getOriginalFilename());//originalFilename
$parsedFile->setCopyRequiresWriterPermission($gdrivefile->getCopyRequiresWriterPermission());//copyRequiresWriterPermission
/*complex permissions*/
/*
contentHints.thumbnail.image
contentHints.thumbnail.mimeType
contentHints.indexableText
*/
$contenthints=$gdrivefile->getContentHints();//could be null meaning further functions eg getThumbnail cause exception
if($contenthints){
$parsedFile->setContentHints($contenthints->getThumbnail()->getImage());
$parsedFile->setContentHints($contenthints->getThumbnail()->getMimeType());
$parsedFile->setContentHints($contenthints->getIndexableText());
}
/*no function to get indiviual attributes*/
/*
contentRestrictions[].readOnly
ccontentRestrictions[].reason
*/
$parsedFile->setContentRestrictions($gdrivefile->getContentRestrictions());
//</ end>
return $parsedFile;
}
This is proving a bit successful but this is original meta
the above code does move it, with seemingly proper meta data, created time and EXIF data is now intact
The problem is that you are using file.update which uses HTTP PATCH methodology. By using a PATCH its going to try to update all of the properties in the file object that you send.
You did a file.get and you included fileds *
$fromFile = $this->service->files->get($fileID, ['fields' => '*']);
By including 'fields' => '*' you told the API to return to you all of the properties that the file has. A file resource has a lot of properties and they are not all writeable.
By sending the file.update method all of the fields you are telling it you want to update everything including some of the properties that you are not allowed to update. To which the api responds with fieldNotWritable
The soultion would be to do the following
$fromFile = $this->service->files->get($fileID, ['fields' => 'parents, id']);
Which will cause the method to only return the two parameters that you actual need. The id and the parents parameter. (TBH you may just need the parents part, I would need to test that.)
{
"id": "1x8-vD-XiA5Spf3qp8x2wltablGF22Lpwup8VtxNY",
"parents": ["0B1bbSFgVLpoXcVDRFRF8tTkU"
]
}
You should then be allowed to update the parent and move your file.
I don't know how to do in laravel but the problem might be
I have also faced the same problem some time and after searching internet for over months found nothing and one day Referring to documentation, seen that shared
isn't a writable field.
That's it, if you are sharing the File and trying to move the file it wouldn't move. Try to un-share the File and then try to move the File it would be done.
I ended up creating a custom function to only include writeable attributes as specified in the Google Documentation:
public function getParsedWritableFile($gdrivefile) {
$gdrivefile = new \Google_Service_Drive_DriveFile();//comment or delete, just here to check auto complete function names
$parsedFile = new \Google_Service_Drive_DriveFile();
// currently only allow these according to https://developers.google.com/drive/api/v3/reference/files#resource-representations
$parsedFile->setName($gdrivefile->getName());//name
$parsedFile->setMimeType($gdrivefile->getMimeType());//mimeType
$parsedFile->setDescription($gdrivefile->getDescription());//description
$parsedFile->setStarred($gdrivefile->getStarred());//starred
$parsedFile->setTrashed($gdrivefile->getTrashed());//trashed
$parsedFile->setParents($gdrivefile->getParents());//parents
$parsedFile->setProperties($gdrivefile->getProperties());//properties [object]
$parsedFile->setAppProperties($gdrivefile->getAppProperties());//appProperties [object]
$parsedFile->setCreatedTime($gdrivefile->getCreatedTime());//createdTime
$parsedFile->setModifiedTime($gdrivefile->getModifiedTime());//modifiedTime
$parsedFile->setWritersCanShare($gdrivefile->getWritersCanShare());//writersCanShare
$parsedFile->setViewedByMeTime($gdrivefile->getViewedByMeTime());//viewedByMeTime
$parsedFile->setFolderColorRgb($gdrivefile->getFolderColorRgb());//folderColorRgb
$parsedFile->setOriginalFilename($gdrivefile->getOriginalFilename());//originalFilename
$parsedFile->setCopyRequiresWriterPermission($gdrivefile->getCopyRequiresWriterPermission());//copyRequiresWriterPermission
/*complex permissions*/
/*
contentHints.thumbnail.image
contentHints.thumbnail.mimeType
contentHints.indexableText
*/
$contenthints=$gdrivefile->getContentHints();//could be null meaning further functions eg getThumbnail cause exception
if($contenthints){
$parsedFile->setContentHints($contenthints->getThumbnail()->getImage());
$parsedFile->setContentHints($contenthints->getThumbnail()->getMimeType());
$parsedFile->setContentHints($contenthints->getIndexableText());
}
/*no function to get indiviual attributes*/
/*
contentRestrictions[].readOnly
ccontentRestrictions[].reason
*/
$parsedFile->setContentRestrictions($gdrivefile->getContentRestrictions());
return $parsedFile;
}
and called with
public function moveFileFromTo($fileID, $toParentID) {
$fromFile = $this->service->files->get($fileID, ["fields" => "id,parents"]);
$fromFile = $this->getParsedWritableFile($fromFile);
$fromFile->setParents($toParentID);
$this->service->files->update($fileID, $fromFile, ['addParents' => $toParentID]);
}
I have a Symfony app which exposes a collection of JSON web services used by a mobile app.
On the last few days we are having many concurrent users using the app (~5000 accesses per day) and a Doctrine error started to "randomly" appear in my logs. It appears about 2-3 times per day and this is the error:
Uncaught PHP Exception Doctrine\DBAL\Exception\DriverException: "An exception occurred while executing 'UPDATE fos_user_user SET current_crystals = ?, max_crystals = ?, updated_at = ? WHERE id = ?' with params [31, 34, "2017-12-19 09:31:18", 807]:
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction" at /var/www/html/rollinz_cms/releases/98/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractMySQLDriver.php line 115
It seems it cannot get the lock while updating the users table. The controller code is the following:
/**
* #Rest\Post("/api/badges/{id}/achieve", name="api_achieve_badge")
*/
public function achieveAction(Badge $badge = null)
{
if (!$badge) {
throw new NotFoundHttpException('Badge not found.');
}
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$userBadge = $em->getRepository('AppBundle:UserBadge')->findBy(array(
'user' => $user,
'badge' => $badge,
));
if ($userBadge) {
throw new BadRequestHttpException('Badge already achieved.');
}
$userBadge = new UserBadge();
$userBadge
->setUser($user)
->setBadge($badge)
->setAchievedAt(new \DateTime())
;
$em->persist($userBadge);
// sets the rewards
$user->addCrystals($badge->getCrystals());
$em->flush();
return new ApiResponse(ApiResponse::STATUS_SUCCESS, array(
'current_crystals' => $user->getCurrentCrystals(),
'max_crystals' => $user->getMaxCrystals(),
));
}
I looked into MySQL and Doctrine documentation but I couldn't find a reliable solution. Doctrine suggests retrying the transaction but it doesn't show an actual example:
https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlock-example.html
try {
// process stuff
} catch (\Doctrine\DBAL\Exception\RetryableException $e) {
// retry the processing
}
This posts suggests retrying the transaction. How can I do it?
Could it be a server problem (too many accesses) and I must boost the server or the code is wrong and I must explicitly handle the deadlock in my code?
This is a MySQL issue. Multiple simultaneous transactions blocking the same resources.
Check if you have cronjobs that may block the records for long times.
Otherwise is just concurrent requests updating the same data, you may have better knowledge where this data gets updated.
Dirty attempt for a retry in php:
$retry=0;
while (true) {
try {
// some more code
$em->flush();
return new ApiResponse(ApiResponse::STATUS_SUCCESS, array(
'current_crystals' => $user->getCurrentCrystals(),
'max_crystals' => $user->getMaxCrystals(),
));
} catch (DriverException $e) {
$retry++;
if($retry>3) { throw $e; }
sleep(1); //optional
}
}
Albert's solution is the right one but you also must recreate a new EntityManager in the catch clause using resetManager() of your ManagerRegistry. You'll get exceptions if you continue to use the old EntityManager and its behavior will be unpredictable. Beware of the references to the old EntityManager too.
This issue will be hopefully corrected in Doctrine 3: See issue
Until then, here is my suggestion to handle the problem nicely: Custom EntityManager
I use JFactory::getApplication()->enqueueMessage('Message goes here', 'error') to show users the request could not be processed, it works OK but Joomla orders the messages in the sequence they occur. Because my message happens before the Joomla save error is captured, the user sees this message:
you cannot do this operation //my message
Save failed with the following error: //Joomla message
I want to invert the order and have Joomla message as it is, followed by my message so that it makes sense:
Save failed with the following error: // Joomla message
you cannot do this operation // my message
Is that possible? (without language translation or overrides?)
After help from answers, I could do the inversion: 1st message is a placeholder to be searched using getMessageQueue(). Although you could delete messages in J.2.5 it is no longer possible with J.3+ (https://developer.joomla.org/joomlacode-archive/issue-33270.html). The solution is to reflect the class to unprotect the queue and replace it.
public static function reorderMessages()
{
//error messages
$err01 = JText::_('COM_COMPONENT_MESSAGE1');
//you can adapt and add other messages here
$app = JFactory::getApplication();
$new_messages = array();
$replacement_found = null;
//mirror protected $_messageQueue
$appReflection = new ReflectionClass(get_class($app));
$_messageQueue = $appReflection->getProperty('_messageQueue');
$_messageQueue->setAccessible(true);
//get messages
$messages = $app->getMessageQueue();
foreach($messages as $key=>$message)
{
if($messages[$key]['message'] == 'MESSAGE_TO_REPLACE' && $messages[$key]['type'] == 'error' )
{
$replacement_found = 1;
continue;
}
$new_messages[] = $message;
}
if($replacement_found)
{
//save all messages
$_messageQueue->setValue($app, $new_messages);
//add replacement message to the end of the queue
$app->enqueueMessage(JText::_($err01, 'error');
}
return true;
}
Be very careful where to call the function, if the message queue is empty Joomla will return an error and break your code. Make sure you have enqueued the 1st message before calling the function.
You can use getMessageQueue() on the application object (i.e., $myApp = JFactory::getApplication()) to get a copy of the message queue array. You can clear the message queue by passing true to the getMessageQueue()` function call. It will still return a copy of the system message queue array.
You could then use regex's to find the keys in the array and reorder them. I would find the system error message in the translation file, and use the error message key from the translation .ini file (instead of the actual text of the error message) for the regex search so it doesn't break if the error message changes. I'd also do it in a plugin and on a late lifecycle hook (maybe the onBeforeRender event).
You can save the modified message queue array back to the JApplication class instance using the application object's enqueueMessage() method, which has this signature:
enqueueMessage(string $msg, string $type = 'message') : void
source
I apologize in advance for my ignorance of CodeIgniter and the MVC system.
I'm helping a family member with their business website and up until now I've been able to complete most of the required changes just by using logic but now I've hit a dead end. I don't plan to continue supporting them as I'm obviously no CodeIgniter expert. But I'm hoping to leave the website at least functional, so that they can start using it.
I simply want to create a new "page" within the website but it seems impossible. If I can achieve this I think I can figure everything else out on my own.
For example I currently have a "page" for Cancelled Jobs. It the navigation HTML it is linked to like this:
http://localhost/admin/modules/cancelled_jobs
and has a corresponding file here: admin/application/controllers/cancelled_jobs.php
which contains this php code:
class Cancelled_jobs extends CIID_Controller {
public function __construct()
{
parent::__construct();
$this->set_table('job', 'Cancelled Job', 'Cancelled Jobs');
$this->allow_delete = false;
$this->allow_cancel = false;
$this->allow_edit = false;
$this->allow_reactivate = true;
$this->allow_add = false;
$this->overview
->add_item('Job No', 'active', 'job_id')
->add_item('Client', 'active|relationship', 'client.name')
->add_item('Name', 'active', 'name')
->add_item('Status', 'active|relationship', 'job_status.name')
->add_item('Assignee', 'active|relationship', 'team_member.name')
->add_item('Scheduled Date', 'active', 'scheduled_date')
->where("job.cancel_job = '1'")
->order_by('job.created_date DESC');
$this->init();
}
}
I would like to create a new "page" called Closed Jobs.
I've tried copying admin/application/controllers/cancelled_jobs.php and renaming it closed_jobs.php and changing the first line of code to read:
class Closed_jobs extends CIID_Controller {
I then add a link in the navigation HTML:
http://localhost/admin/modules/closed_jobs
However, when clicked, this only results in a "404 Page Not Found" error.
Can anyone point out what I'm missing in the process of creating a new page?
Generally, CodeIgniter URLstructure is:
sitename.com/controller_name/function_name/parameter_1/parameter_2/parameter_3/
You can add as many parameters as you want.
To access
modules/closed_jobs:
Add a new function in the controller modules
function closed_jobs() {
$this->load->view('closed_jobs');
}
And create a view closed_jobs.php
in application/views
Repeat the same for cancelled_jobs
I am trying to bulk upload 'Opportunities' into Salesforce using PHP Toolkit 20.0 and the Enterprise SOAP API.
The way I have found to do it is to create an Opportunity object and then create it in Salesforce via the SOAP API, then on the response I take the Id and use that for each 1..n OpportunityLineItems that exists for that Opportunity.
This isn't very efficient as it use 2 SOAP API calls and when done in bulk uses a lot of resources and is liable to timeouts. (I do not want to reduce the amount sent in one go as the API calls are limited)
Therefore is there a way to create both the Opportunity and it's OpportunityLineItems in a single API call?
I tried the following:
$opp = new stdClass();
$opp->Name = 'Opp1';
$opp->StageName = 'Closed Won';
$opp->Account = new stdClass();
$opp->Account->Custom_ID__c = '1234';
$opp->Pricebook2Id = '...';
$opp->OpportunityLineItems = array();
$opp->OpportunityLineItems[0] = new stdClass();
$opp->OpportunityLineItems[0]->Description = 'Product name';
$opp->OpportunityLineItems[0]->Quantity = 1;
$opp->OpportunityLineItems[0]->UnitPrice = 10.00;
...
$opp->OpportunityLineItems[n] = new stdClass();
$opp->OpportunityLineItems[n]->Description = 'Product name';
$opp->OpportunityLineItems[n]->Quantity = 1;
$opp->OpportunityLineItems[n]->UnitPrice = 10.00;
But it resulted in:
INVALID_FIELD: No such column 'OpportunityLineItems' on entity 'Opportunity'. If you are attempting to use a custom field, be sure to append the '__c' after the custom field name. Please reference your WSDL or the describe call for the appropriate names.
Which was to be expected as the WSDL file states that OpportunityLineItems is of type tns:QueryResult rather than an ens:
EDIT: Complete overhaul to show how multiple opps can be added at same time. Should be useful if you can stagger their creation somehow (store them in your local database and upload only when you got a couple/sufficient time has passed/user pressed "flush the queue" button).
Warning, the code actually looks more scary now, you might be better of checking previous version in edit history first.
It won't be too hard to create an Apex class that would accept incoming request with 2 parameters and attempt to insert them. Go to Setup->Develop->Classes->New and try this:
global with sharing class OpportunityLinkedInsert{
static webservice Opportunity insertSingle(Opportunity opp, OpportunityLineItem[] lines){
if(opp == null || lines == null){
throw new IntegrationException('Invalid data');
}
Opportunity[] result = insertMultiple(new List<Opportunity>{opp}, new List<List<OpportunityLineItem>>{lines});
return result[0]; // I imagine you want the Id back :)
}
/* I think SOAP doesn't handle method overloading well so this method has different name.
'lines' are list of lists (jagged array if you like) so opps[i] will be inserted and then lines[i] will be linked to it etc.
You can insert up to 10,000 rows in one go with this function (remember to count items in both arrays).
*/
static webservice List<Opportunity> insertMultiple(List<Opportunity> opps, List<List<OpportunityLineItem>> lines){
if(opps == null || lines == null || opps.size() == 0 || opps.size() != lines.size()){
throw new IntegrationException('Invalid data');
}
insert opps;
// I need to flatten the structure before I insert it.
List<OpportunityLineItem> linesToInsert = new List<OpportunityLineItem>();
for(Integer i = 0; i < opps.size(); ++i){
List<OpportunityLineItem> linesForOne = lines[i];
if(linesForOne != null && !linesForOne.isEmpty()){
for(Integer j = 0; j < linesForOne.size(); ++j){
linesForOne[j].OpportunityId = opps[i].Id;
}
linesToInsert.addAll(linesForOne);
}
}
insert linesToInsert;
return opps;
}
// helper class to throw custom errors
public class IntegrationException extends Exception{}
}
You'll also need an unit test class before this can go to your production organisation. Something like that should do (needs to be filled with couple more things before being 100% usable, see this question for more info).
#isTest
public class OpportunityLinkedInsertTest{
private static List<Opportunity> opps;
private static List<List<OpportunityLineItem>> items;
#isTest
public static void checSingleOppkErrorFlow(){
try{
OpportunityLinkedInsert.insertSingle(null, null);
System.assert(false, 'It should have failed on null values');
} catch(Exception e){
System.assertEquals('Invalid data',e.getMessage());
}
}
#isTest
public static void checkMultiOppErrorFlow(){
prepareTestData();
opps.remove(1);
try{
OpportunityLinkedInsert.insertMultiple(opps, items);
System.assert(false, 'It should have failed on list size mismatch');
} catch(Exception e){
System.assertEquals('Invalid data',e.getMessage());
}
}
#isTest
public static void checkSuccessFlow(){
prepareTestData();
List<Opportunity> insertResults = OpportunityLinkedInsert.insertMultiple(opps, items);
List<Opportunity> check = [SELECT Id, Name,
(SELECT Id FROM OpportunityLineItems)
FROM Opportunity
WHERE Id IN :insertResults
ORDER BY Name];
System.assertEquals(items[0].size(), check[0].OpportunityLineItems.size(), 'Opp 1 should have 1 product added to it');
System.assertEquals(items[1].size(), check[0].OpportunityLineItems.size(), 'Opp 3 should have 1 products');
}
// Helper method we can reuse in several tests. Creates 2 Opportunities with different number of line items.
private static void prepareTestData(){
opps = new List<Opportunity>{
new Opportunity(Name = 'Opp 1', StageName = 'Prospecting', CloseDate = System.today() + 10),
new Opportunity(Name = 'Opp 2', StageName = 'Closed Won', CloseDate = System.today())
};
// You might have to fill in more fields here!
// Products are quite painful to insert with all their standard/custom pricebook dependencies etc...
items = new List<List<OpportunityLineItem>>{
new List<OpportunityLineItem>{
new OpportunityLineItem(Description = 'Opp 1, Product 1', Quantity = 1, UnitPrice = 10)
},
new List<OpportunityLineItem>{
new OpportunityLineItem(Description = 'Opp 2, Product 1', Quantity = 1, UnitPrice = 10),
new OpportunityLineItem(Description = 'Opp 2, Product 2', Quantity = 1, UnitPrice = 10),
new OpportunityLineItem(Description = 'Opp 2, Product 3', Quantity = 1, UnitPrice = 10)
}
};
}
}
That's pretty much in terms of Apex code.
If either of inserts will fail you'll get a SOAP Exception back. This is also a bit better in terms of transactions, ACID etc - if insert of your line items will fail, are you prepared to clean it up from PHP side? What if some automated email notifications etc. were set up in Salesforce and already sent? Having it in one call to Apex will make sure whole request will be rolled back, pretty much like stored procedures work in the databases.
Try to create these classes in sandbox, then locate first one on the list of classes. It will have a link to generate a WSDL file which you can use to generate your PHP classes.
Going to the second one you'll see a "Run Tests" button. You'll have to make sure the test passes before pushing it to your production org - but that's whole new world of programming on the platform for you :)