commit bde8aa5e5df19f8f04a687e69cd0acfebd9c21e1 Author: unknown Date: Thu Oct 13 11:55:19 2022 -0400 sample code diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.info.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.info.yml new file mode 100644 index 0000000..c278613 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.info.yml @@ -0,0 +1,9 @@ +name: AMCPR User Profile +type: module +description: 'Handles AMCPR user profiles.' +core_version_requirement: ^8.9 || ^9 +package: 'AMCPR Profile' +dependencies: + - profile:profile + - amcpr_content:amcpr_content + - amcpr_components:amcpr_components diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.install b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.install new file mode 100644 index 0000000..233aa0e --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.install @@ -0,0 +1,32 @@ + 'reviewer', + 'label' => 'Reviewer' + ]); + $role->save(); + } + catch (EntityStorageException $e) { + } + } + } +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.libraries.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.libraries.yml new file mode 100644 index 0000000..e31d2f7 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.libraries.yml @@ -0,0 +1,5 @@ +supplier-wizard: + version: 1.x + css: + theme: + css/supplier-wizard.css: {} diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.menu.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.menu.yml new file mode 100644 index 0000000..7884833 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.menu.yml @@ -0,0 +1,7 @@ +amcpr_user_profile.admin_config: + title: 'AMCPR Supplier Generic Email Servers' + description: 'Configure AMCPR Supplier Email Server settings' + parent: amcpr_components.admin_config_section + route_name: amcpr_user_profile.manage_supplier_settings + weight: -100 + diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.task.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.task.yml new file mode 100644 index 0000000..ee13c45 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.links.task.yml @@ -0,0 +1,25 @@ +# Personal Profile View Tab +amcpr_user_profile.profile_view: + title: 'View' + route_name: amcpr_user_profile.profile + base_route: amcpr_user_profile.profile + +# Personal Profile Edit Tab +amcpr_user_profile.profile_edit: + title: 'Edit' + route_name: amcpr_user_profile.profile_edit + base_route: amcpr_user_profile.profile + + +# Supplier Profile View Tab +amcpr_user_profile.profile_supplier_view: + title: 'View' + route_name: amcpr_user_profile.profile_supplier + base_route: amcpr_user_profile.profile_supplier + +# Supplier Profile Edit Tab +amcpr_user_profile.profile_supplier_edit: + title: 'Edit' + route_name: amcpr_user_profile.profile_supplier_edit + base_route: amcpr_user_profile.profile_supplier + diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.module b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.module new file mode 100644 index 0000000..c52fd4a --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.module @@ -0,0 +1,472 @@ +setFormClass('dashboard', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard_step_1', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard_step_2', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard_step_3', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard_step_4', 'Drupal\profile\Form\ProfileForm'); + $entity_types['profile']->setFormClass('wizard_step_5', 'Drupal\profile\Form\ProfileForm'); +} + +/** + * Implements hook_form_FORM_ID_alter(). + * Redirect users to front page after registration. + */ +function amcpr_user_profile_form_user_register_form_alter(&$form, FormStateInterface $form_state, $form_id) { + $form['#attributes']['autocomplete'] = 'off'; + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_user_register_form_submit_redirect'; +} + +/** + * User register form callback. + */ +function amcpr_user_profile_user_register_form_submit_redirect(&$form, FormStateInterface $form_state) { + $form_state->setRedirect(''); + $form_state->disableRedirect(FALSE); +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function amcpr_user_profile_form_profile_personal_wizard_form_alter(&$form, FormStateInterface $form_state, $form_id) { + + $form['field_business_category']['#states'] = [ + 'visible' => [ + ':input[name="field_represent_company[value]"]' => ['checked' => TRUE], + ], + ]; +/* + $form['field_company_name']['#states'] = [ + 'visible' => [ + ':input[name="field_represent_company[value]"]' => ['checked' => TRUE], + ], + ]; +*/ + + foreach (array_keys($form['actions']) as $action) { + if ($action == 'submit' && isset($form['actions'][$action]['#type']) && $form['actions'][$action]['#type'] === 'submit') { + array_unshift($form['actions'][$action]['#submit'], 'amcpr_user_profile_form_profile_personal_wizard_form_submit_before'); + $form['actions'][$action]['#submit'][] = 'amcpr_user_profile_form_profile_personal_wizard_form_submit_after'; + + $form['actions'][$action]['#value'] = t('Next'); + $form['actions'][$action]['#weight'] = 10; + + break; + } + } + + $form['actions']['previous'] = [ + '#type' => 'submit', + '#value' => t('Back'), + '#submit' => [ + 'amcpr_user_profile_form_profile_personal_wizard_form_submit_previous' + ], + '#weight' => 0, + ]; + +} + +/** + * Previous/Back button submit handler callback. + */ +function amcpr_user_profile_form_profile_personal_wizard_form_submit_previous(array $form, FormStateInterface $form_state) { + $form_state->setRedirect('amcpr_user_profile.profile_wizard'); +} + +/** + * Save/Next button submit handler callback. + * Executes before other submit handlers. + * @see \Drupal\profile\Form\ProfileForm::save() + */ +function amcpr_user_profile_form_profile_personal_wizard_form_submit_before(array $form, FormStateInterface $form_state) { + // Disables all redirects initiated by saving the profile entity. + $form_state->disableRedirect(); +} + +/** + * Save/Next button submit handler callback. + * Executes last after all other submit handlers. + */ +function amcpr_user_profile_form_profile_personal_wizard_form_submit_after(array $form, FormStateInterface $form_state) { + $errors = $form_state->getErrors(); + // We disabled all previous redirects so that we can inject our own. + + if (!$errors) { + if ($form_state->get('skip_supplier') == null || !$form_state->get('skip_supplier')) { + // Advance form to Step 3: Supplier Prompt + $form_state->setRedirect('amcpr_user_profile.profile_wizard_supplier_prompt'); + } else { + // The user is already associated with a supplier (based on email domain) + // Advance form to Step 4: Profile wizard end + $form_state->setRedirect('amcpr_user_profile.profile_wizard_finish'); + } + $form_state->disableRedirect(FALSE); + } + +} +/** + * Implements hook_user_login(). + */ +function amcpr_user_profile_user_insert(Drupal\user\Entity\User $entity){ + AuditUtility::audit('User Registration','INFO', $entity->get('uid')->getString()); +} + +/** + * Implements hook_user_login(). + */ +function amcpr_user_profile_user_login(UserInterface $account) { + + // Don't force the user to go through the Profile wizard on password resets. + $route = RouteMatch::createFromRequest(\Drupal::request()); + if ($route->getRouteName() === 'user.reset.login') { + return; + } + + // Redirect the user to the Profile wizard if their profile is not complete. + // A flag to indicate incompleteness is set at the time of registration. + $isIncompleteFlag = $account->get('field_profile_incomplete')->getValue(); + + // See if the flag value is set to ON (numerical 1). + // It means the user has not completed their profile. + if (!empty($isIncompleteFlag) && $isIncompleteFlag[0]['value']) { + $response = new RedirectResponse(URL::fromRoute('amcpr_user_profile.profile_wizard') + ->toString()); + //$response->send(); + } + // A destination was set, probably on an exception controller, + elseif (!\Drupal::request()->query->has('destination')) { + $response = new RedirectResponse(URL::fromRoute('')->toString()); + } + else { + $response = new RedirectResponse(\Drupal::request()->query->get('destination')); + } + + //$response->send(); + // Updating to 9.2 broke sending redirect response from hook_user_login. + // See https://www.drupal.org/project/drupal/issues/2852657#comment-14151263. + \Drupal::service('request_stack') + ->getCurrentRequest() + ->query->set('destination', $response->getTargetUrl()); +} + +/** + * Implements hook_form_alter(). + */ +function amcpr_user_profile_form_alter(&$form, FormStateInterface $form_state, $form_id) { + // For the ability to change the supplier profile based on email server, restrict user's ability to alter their email address once set + if ($form_id == 'user_form'){ + $user = \Drupal::currentUser(); + if (!$user->hasPermission('amcpr repo admin')){ + $form['account']['mail']['#disabled'] = true; + $form['account']['mail']['#description'] = t('Only AMCPR Administrators can change an email address.'); + } + } + + if (!preg_match('/^profile_supplier_wizard_step_(\d+)_form$/', $form_id)) { + return; + } + + $step = $form_state->get('step'); + + foreach (array_keys($form['actions']) as $action) { + if ($action == 'submit' && isset($form['actions'][$action]['#type']) && $form['actions'][$action]['#type'] === 'submit') { + array_unshift($form['actions'][$action]['#submit'], 'amcpr_user_profile_form_profile_supplier_wizard_form_submit_disable'); + $form['actions'][$action]['#submit'][] = 'amcpr_user_profile_form_profile_supplier_wizard_form_submit_next'; + + $form['actions'][$action]['#value'] = ($step < SupplierProfileWizardController::MAX_STEPS) + ? t('Next') + : t('Submit'); + + if ($step >= SupplierProfileWizardController::MAX_STEPS) { + $form['actions'][$action]['#submit'][] = 'amcpr_user_profile_create_supplier_in_repo'; + } + + $form['actions'][$action]['#weight'] = 10; + + break; + } + } + + if ($step > 1) { + $form['actions']['previous'] = [ + '#type' => 'submit', + '#value' => t('Back'), + '#submit' => [ + 'amcpr_user_profile_form_profile_supplier_wizard_form_submit_previous' + ], + '#weight' => 0, + ]; + } + + $form['#attached']['library'][] = 'amcpr_user_profile/supplier-wizard'; +} + + + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function amcpr_user_profile_form_profile_supplier_dashboard_form_alter(&$form, FormStateInterface $form_state, $form_id) { + // Add an additional submit handler to disable redirects after this form + // is submitted. Make sure our submit handler is first to execute. + array_unshift($form['actions']['submit']['#submit'], 'amcpr_user_profile_submit_handler_disable_redirect'); + + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_form_profile_supplier_dashboard_redirect'; + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_push_to_qex'; +} + +function amcpr_user_profile_form_profile_supplier_dashboard_redirect(array $form, FormStateInterface $form_state) { + $form_state->setRedirect('amcpr_user_profile.profile_supplier_edit'); + $form_state->disableRedirect(FALSE); +} + +/** + * Previous/Back button submit handler callback. + * + */ +function amcpr_user_profile_form_profile_supplier_wizard_form_submit_previous(array $form, FormStateInterface $form_state) { + $step = $form_state->get('step'); + $form_state->setRedirect(SupplierProfileWizardController::getPreviousRoute($step)); +} + +/** + * Save/Next button submit handler callback. + * Executes before other submit handlers. + * @see \Drupal\profile\Form\ProfileForm::save() + */ +function amcpr_user_profile_form_profile_supplier_wizard_form_submit_disable(array $form, FormStateInterface $form_state) { + // Disables all redirects initiated by saving the profile entity. + $form_state->disableRedirect(); +} + +/** + * Appended to the end of callbacks if it was the last step of the supplier + */ +function amcpr_user_profile_create_supplier_in_repo(array $form, FormStateInterface $form_state) { + $errors = $form_state->getErrors(); + + if (!$errors) { + amcpr_user_profile_create_supplier_from_current_user(); + } +} + +/** + * Save/Next button submit handler callback. + * Executes last after all other submit handlers. + */ +function amcpr_user_profile_form_profile_supplier_wizard_form_submit_next(array $form, FormStateInterface $form_state) { + $errors = $form_state->getErrors(); + + // We disabled all previous redirects so that we can inject our own. + // Advance form to next step. + if (!$errors) { + $step = $form_state->get('step'); + $form_state->setRedirect(SupplierProfileWizardController::getNextRoute($step)); + $form_state->disableRedirect(FALSE); + } +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function amcpr_user_profile_form_profile_personal_dashboard_form_alter(&$form, FormStateInterface $form_state, $form_id) { + + $form['field_business_category']['#states'] = [ + 'visible' => [ + ':input[name="field_represent_company[value]"]' => ['checked' => TRUE], + ], + ]; +/* + $form['field_company_name']['#states'] = [ + 'visible' => [ + ':input[name="field_represent_company[value]"]' => ['checked' => TRUE], + ], + ]; +*/ + $form_state->setRedirect('amcpr_user_profile.profile'); + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_form_profile_personal_dashboard_redirect'; +} + +function amcpr_user_profile_form_profile_personal_dashboard_redirect(array $form, FormStateInterface $form_state) { + $form_state->setRedirect('amcpr_user_profile.profile'); + $form_state->disableRedirect(FALSE); +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function amcpr_user_profile_form_user_form_alter(&$form, FormStateInterface $form_state, $form_id) { + + /** @var Drupal\user\Entity\User $user */ + $user = User::load(\Drupal::currentUser()->id()); + $passResetToken = \Drupal::request()->query->get('pass-reset-token'); + + // User could be resetting password using a one-tine link. Tweak the form accordingly. + if ($passResetToken) { + _amcpr_user_profile_form_user_form_alter_scenario_reset($form, $form_state, $user); + } + + // Add an additional submit handler to disable redirects after this form + // is submitted. Make sure our submit handler is first to execute. + array_unshift($form['actions']['submit']['#submit'], 'amcpr_user_profile_submit_handler_disable_redirect'); +} + +function _amcpr_user_profile_form_user_form_alter_scenario_reset(&$form, FormStateInterface $form_state, Drupal\user\Entity\User $user) { + + $form['#title'] = t('Password reset'); + $form['actions']['submit']['#value'] = t('Update'); + + // Remove some form items when the form is being accessed via one-time link. + unset($form['field_user_avatar']); + unset($form['account']['mail']); + + // Determine whether the User is resetting password during the very first + // log in (post registration). + // A flag is set automatically at the time of registration to indicate + // the user has not set their password yet. + + // Fetch the value of the password flag. + $isNotSetFlag = $user->get('field_password_not_set')->getValue(); + + // See if the field value is set to ON (numerical 1). + // It means the user has not set their first password. + if (!empty($isNotSetFlag) && $isNotSetFlag[0]['value']) { + // Add an additional submit handler to remove the flag once the password is set. + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_submit_handler_remove_password_flag'; + } + + // Log the user out after they reset their password. + $form['actions']['submit']['#submit'][] = 'amcpr_user_profile_submit_handler_logout'; +} + +/** + * Submit handler to disable redirect after form submission. + */ +function amcpr_user_profile_submit_handler_disable_redirect(array $form, FormStateInterface $form_state) { + // Disables all redirects initiated by the form's default submit handler. + $form_state->disableRedirect(); +} + +/** + * Submit handler callback for User Form. + * Removes password flag once the user sets their password. + * @throws EntityStorageException + */ +function amcpr_user_profile_submit_handler_remove_password_flag(array $form, FormStateInterface $form_state) { + $errors = $form_state->getErrors(); + + if (!$errors) { + $user = User::load(\Drupal::currentUser()->id()); + $user->set('field_password_not_set', 0); + $user->save(); + } +} + +/** + * Submit handler callback for User Form. + * Logs the user out. + */ +function amcpr_user_profile_submit_handler_logout(array $form, FormStateInterface $form_state) { + $errors = $form_state->getErrors(); + + if (!$errors) { + $form_state->setRedirect( + 'user.logout', + ['logout_method' => 'reset'] + ); + + $form_state->disableRedirect(FALSE); + } +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function amcpr_user_profile_form_user_login_form_alter(&$form, FormStateInterface $form_state) { + $form['reset_password_link'] = [ + '#type' => 'link', + '#title' => t('Reset Password'), + '#url' => Url::fromRoute('user.pass'), + '#weight' => 100, + ]; +} + +/** + * Implements hook_menu_local_tasks_alter(). + */ +function amcpr_user_profile_menu_local_tasks_alter(&$data, $route_name) { + if (!empty($data['tabs'])) { + if ($route_name == 'user.login' + || $route_name == 'user.register' + || $route_name == 'user.pass') { + + if ( isset($data['tabs'][0]['user.login']) ) { + unset($data['tabs'][0]['user.login']); + } + if ( isset($data['tabs'][0]['user.register']) ) { + unset($data['tabs'][0]['user.register']); + } + if ( isset($data['tabs'][0]['user.pass']) ) { + unset($data['tabs'][0]['user.pass']); + } + } + + if ($route_name == 'entity.user.canonical' + || $route_name == 'entity.user.edit_form' + || $route_name == 'profile.user_page.single') { + + $tabs = array_keys($data['tabs'][0]); + foreach ($tabs as $tab) { + unset($data['tabs'][0][$tab]); + } + } + } + +} + +/** + * Implements hook_inline_entity_form_table_fields_alter(). + */ +function amcpr_user_profile_inline_entity_form_table_fields_alter(&$fields, $context) { + + if ($context['entity_type'] == 'node') { + $fields['label']['label'] = t('Make'); + $fields['field_equipment_m'] = [ + 'type' => 'field', + 'label' => t('Model'), + 'weight' => 101, + ]; + $fields['field_equipment_quantity'] = [ + 'type' => 'field', + 'label' => t('Quantity'), + 'weight' => 110, + ]; + + unset($fields['status']); + } +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.routing.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.routing.yml new file mode 100644 index 0000000..9595154 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.routing.yml @@ -0,0 +1,198 @@ + +amcpr_user_profile.manage_supplier_settings: + path: '/admin/config/supplier_settings' + defaults: + _form: '\Drupal\amcpr_user_profile\Form\SupplierSettingsForm' + _title: 'Manage Supplier Rules' + requirements: + _permission: 'amcpr repo admin' + +amcpr_user_profile.profile_wizard_exit: + path: '/profile/wizard/exit' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::exitCompleteProfileWizard' + _title: 'Exit Profile Wizard' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Wizard: Step 1 +amcpr_user_profile.profile_wizard: + path: '/profile/wizard' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::showCompleteProfileWizard' + _title: 'Complete Your Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Wizard: Step 2 +amcpr_user_profile.profile_wizard_additional_info: + path: '/profile/wizard/additional-info' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::showAdditionalProfileInfoWizard' + _title: 'Complete Your Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Wizard: Step 3 +amcpr_user_profile.profile_wizard_supplier_prompt: + path: '/profile/wizard/supplier_prompt' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::showSupplierPromptWizard' + _title: 'Complete Your Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Wizard: Step 4a +amcpr_user_profile.profile_wizard_finish: + path: '/profile/wizard/finish' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::finishCompleteProfileWizard' + _title: 'Complete Your Profile' + continue_supplier: FALSE + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Wizard: Step 4b +amcpr_user_profile.profile_wizard_continue_supplier: + path: '/profile/wizard/supplier-info' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\PersonalProfileWizardController::finishCompleteProfileWizard' + _title: 'Complete Your Profile' + continue_supplier: 'TRUE' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + + + +# Supplier Profile Wizard: Step 1 +amcpr_user_profile.supplier_profile_wizard: + path: '/profile/supplier/wizard' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::business' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Supplier Profile Wizard: Step 2 +amcpr_user_profile.supplier_profile_wizard_certifications: + path: '/profile/supplier/wizard/certifications' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::certifications' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Supplier Profile Wizard: Step 3 +amcpr_user_profile.supplier_profile_wizard_materials: + path: '/profile/supplier/wizard/materials' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::materials' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Supplier Profile Wizard: Step 4 +amcpr_user_profile.supplier_profile_wizard_equipments: + path: '/profile/supplier/wizard/equipments' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::equipments' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Supplier Profile Wizard: Step 5 +amcpr_user_profile.supplier_profile_wizard_specialities: + path: '/profile/supplier/wizard/specialities' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::specialities' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Supplier Profile Wizard: Step 6 +amcpr_user_profile.supplier_profile_wizard_finish: + path: '/profile/supplier/wizard/finish' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\SupplierProfileWizardController::finishWizard' + _title: 'Create Supplier Profile' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Account Settings Form +amcpr_user_profile.account_preferences: + path: '/account/preferences' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::accountPreferences' + _title: 'Account Preferences' + requirements: + _role: 'authenticated' + options: + no_cache: 'TRUE' + +# Personal Profile Page +amcpr_user_profile.profile: + path: '/account/profile' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::personal' + _title: 'Personal Profile' + requirements: + _custom_access: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::profileAccess' + options: + no_cache: 'TRUE' + +# Personal Profile Form +amcpr_user_profile.profile_edit: + path: '/account/profile/edit' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::personalForm' + _title: 'Personal Profile' + requirements: + _custom_access: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::profileAccess' + options: + no_cache: 'TRUE' + +# Supplier Profile Page +amcpr_user_profile.profile_supplier: + path: '/account/profile/supplier' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::supplier' + _title: 'Supplier Profile' + requirements: + _custom_access: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::supplierProfileAccess' + options: + no_cache: 'TRUE' + +# Supplier Profile Form +amcpr_user_profile.profile_supplier_edit: + path: '/account/profile/supplier/edit' + defaults: + _controller: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::supplierForm' + _title: 'Supplier Profile' + requirements: + _custom_access: '\Drupal\amcpr_user_profile\Controller\ProfileDashboardController::supplierProfileAccess' + options: + no_cache: 'TRUE' diff --git a/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.services.yml b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.services.yml new file mode 100644 index 0000000..b27430c --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/amcpr_user_profile.services.yml @@ -0,0 +1,18 @@ +# Register our Route Subscriber +services: + amcpr_user_profile.route_subscriber: + class: Drupal\amcpr_user_profile\Routing\ProfileRouteSubscriber + tags: + - { name: event_subscriber } + + amcpr_user_profile.request_event_subscriber: + class: Drupal\amcpr_user_profile\EventSubscriber\RequestEventSubscriber + arguments: ['@current_user'] + tags: + - { name: event_subscriber } + + amcpr.user.utility: + class: Drupal\amcpr_user_profile\Service\UserProfileService + arguments: ['@entity_type.manager', '@current_user'] + tags: + - { name: amcpr } diff --git a/ExchangeMarketPlace/amcpr_user_profile/css/supplier-wizard.css b/ExchangeMarketPlace/amcpr_user_profile/css/supplier-wizard.css new file mode 100644 index 0000000..16522db --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/css/supplier-wizard.css @@ -0,0 +1,18 @@ + +/** + *Primary Material Experience Step + */ +#profile-supplier-wizard-step-3-form .layout__region.layout__region--second { + margin: 0 1.5em; +} + +@media screen and (min-width: 40em) { + #profile-supplier-wizard-step-3-form .layout__region.layout__region--first, + #profile-supplier-wizard-step-3-form .layout__region.layout__region--third { + flex: 0 1 31%; + } + + #profile-supplier-wizard-step-3-form .layout__region.layout__region--second { + flex: 0 1 32%; + } +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Controller/PersonalProfileWizardController.php b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/PersonalProfileWizardController.php new file mode 100644 index 0000000..fa72ac5 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/PersonalProfileWizardController.php @@ -0,0 +1,212 @@ +currentUser(); + $this->messenger(); + $this->entityTypeManager(); + $this->entityFormBuilder(); + + $this->amcprUserService = $user_profile_service; + $this->tempStore = $temp_store_factory->get('amcpr_profile_controller'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('amcpr.user.utility'), + $container->get('tempstore.private') + ); + } + + /** + * Present a wizard to complete profile. + * + * @param bool $show_exit_meesage + * Show a message if the flag is set to TRUE. + * + * @param string $route_name + * The name of the route to which to redirect. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function exitCompleteProfileWizard($show_exit_meesage = TRUE, $route_name = '') { + $this->tempStore->delete('step'); + + if ($show_exit_meesage) { + $this->messenger->addMessage($this->t('You have exited from completing the profile.')); + } + + return $this->redirect($route_name); + } + + /** + * Step 1: + * Present a wizard to complete profile. + * + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function showCompleteProfileWizard() { + + if ($this->amcprUserService->isProfileIncomplete()) { + $this->tempStore->set('step', 1); + } + else { + $this->messenger->addMessage($this->t('Your profile has been completed already.')); + return $this->exitCompleteProfileWizard(FALSE); + } + + return [ + '#title' => $this->t("Let's Complete Your Profile"), + '#theme' => 'amcpr_profile_wizard', + '#profiles' => $this->amcprUserService->getProfileTypes(), + '#lead_text' => $this->t('Completing your profile will allow you to be a...'), + '#next_step_path' => Url::fromRoute('amcpr_user_profile.profile_wizard_additional_info')->toString(), + '#exit_wizard_path' => Url::fromRoute('amcpr_user_profile.profile_wizard_exit')->toString(), + ]; + } + + /** + * Step 2: + * Present the Additional Information step in the Complete Profile wizard. + * + * @throws \Drupal\Core\TempStore\TempStoreException + * @throws \Exception + */ + public function showAdditionalProfileInfoWizard() { + $step = $this->tempStore->get('step'); + + // Need to start at step 1. + if (!$step && $step != 1) { + return $this->redirect('amcpr_user_profile.profile_wizard'); + } + + + // get user personal profile + $personalProfile = $this->amcprUserService->getPersonalProfile(); + + if (!$personalProfile) { + $this->messenger->addError($this->t('Registration error... Error code 1200-01.')); + return $this->exitCompleteProfileWizard(FALSE); + } + + // Advance current step. + if ($this->amcprUserService->getSupplierProfile(FALSE) == null) { + //User's email domain is not associated with a supplier, ask if they want to create supplier profile + $this->tempStore->set('step', 2); + $skipSupplier = false; + } else { + //Email is already associated with a supplier, skip supplier step (handled in amcpr_user_profile_form_profile_personal_wizard_form_submit_after) + $this->tempStore->set('step', 3); + $skipSupplier = true; + } + $form = $this->entityFormBuilder->getForm($personalProfile, 'wizard', ['skip_supplier' => $skipSupplier]); + + /** @var \Drupal\Core\Render\Renderer $renderer */ + $renderer = \Drupal::service('renderer'); + + return [ + '#title' => $this->t("Let's Complete Your Profile"), + '#theme' => 'amcpr_profile_wizard_form', + '#form_title' => $this->t("Additional Profile Information"), + //'#form' => $renderer->render($form), + '#form' => $form, + ]; + + } + + /** + * Step 3: + * Present the Supplier Prompt step in the Complete Profile wizard. + * + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function showSupplierPromptWizard() { + $step = $this->tempStore->get('step'); + + // Should come from step 2. + if (!$step && $step != 2) { + return $this->redirect('amcpr_user_profile.profile_wizard'); + } + + // Advance current step. + $this->tempStore->set('step', 3); + + return [ + '#title' => $this->t('Thank you for updating your information!'), + '#theme' => 'amcpr_profile_wizard_prompt', + '#prompt_title' => $this->t("Do you represent a manufacturing organization (supplier)?"), + '#prompt_text' => $this->t("If so, we need some additional information before we can help connect you with organizations in need."), + '#confirm' => [ + 'text' => $this->t('Yes'), + 'path' => Url::fromRoute('amcpr_user_profile.profile_wizard_continue_supplier')->toString(), + ], + '#cancel' => [ + 'text' => $this->t('No'), + 'path' => Url::fromRoute('amcpr_user_profile.profile_wizard_finish')->toString(), + ] + ]; + } + + /** + * Step 4 + * End of complete profile wizard. Update flags accordingly. + * + * @param bool $continue_supplier + * A boolean flag indicating whether to advance to supplier profile info. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function finishCompleteProfileWizard(bool $continue_supplier) { + $step = $this->tempStore->get('step'); + + // Should come from step 3. + if (!$step && $step != 3) { + return $this->redirect('amcpr_user_profile.profile_wizard'); + } + + // Unset incomplete profile flag. + $this->amcprUserService->removeProfileIncompleteFlag(); + $this->amcprUserService->killUserPrimaryNavBlockCache(); + $this->messenger->addMessage($this->t('You have have completed your profile.')); + + if (!$continue_supplier) { + return $this->exitCompleteProfileWizard(FALSE); + } + else { + return $this->exitCompleteProfileWizard(FALSE, 'amcpr_user_profile.supplier_profile_wizard'); + } + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileDashboardController.php b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileDashboardController.php new file mode 100644 index 0000000..01a3179 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileDashboardController.php @@ -0,0 +1,189 @@ +currentUser(); + $this->messenger(); + $this->entityTypeManager(); + $this->entityFormBuilder(); + + $this->amcprUserService = $user_profile_service; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('amcpr.user.utility') + ); + } + + /** + * @deprecated + * + * @return array + * Renderable array. + */ + public function account() { + return [ + '#markup' => '

Deprecated.

', + ]; + } + + /** + * @return array + * Renderable array. + */ + public function accountPreferences() { + $user = $this->amcprUserService->getCurrentUser(); + if ($user) { + $form = $this->entityFormBuilder->getForm($user, 'default'); + + // These elements should only be rendered on the admin interface. + unset($form['account']['name']); + unset($form['account']['status']); + unset($form['account']['roles']); + unset($form['account']['notify']); + + return $form; + } + else { + return [ + '#markup' => '

Something went wrong.

', + ]; + } + } + + /** + * @return array + * Renderable array. + */ + public function personal() { + if (empty($this->currentUser()->id())){ + return $this->redirect('user.login'); + } + $personalProfile = $this->amcprUserService->getPersonalProfile(); + $builder = $this->amcprUserService->getProfileViewBuilder(); + $build = []; + $build['entity'] = [ + 'entity' => $builder->view($personalProfile, 'default') + ]; + + return $build; + } + + /** + * @return array + * Renderable array. + */ + public function personalForm() { + $personalProfile = $this->amcprUserService->getPersonalProfile(TRUE); + return $this->entityFormBuilder->getForm($personalProfile, 'dashboard'); + } + + /** + * @return array + * Renderable array. + */ + public function supplier() { + $supplierProfile = $this->amcprUserService->getSupplierProfile(); + $builder = $this->amcprUserService->getProfileViewBuilder(); + $build = []; + $build['entity'] = [ + 'entity' => $builder->view($supplierProfile, 'default') + ]; + return $build; + } + + /** + * @return array + * Renderable array. + */ + public function supplierForm() { + $currUserEmail = \Drupal::currentUser()->getEmail(); + $currUserServer = strtolower(substr($currUserEmail, strpos($currUserEmail, "@") + 1)); + $configName = (\Drupal\amcpr_user_profile\Form\SupplierSettingsForm::CONFIG_NAME); + $config = \Drupal::config($configName); + $serverListString = $config->get('server_list'); + $serverListArray = preg_split('/(,|\r\n|\n|\r| )/', $serverListString); + $isInServerList = FALSE; + foreach($serverListArray as $indivServer){ + if (strtolower($indivServer) == $currUserServer){ + $isInServerList = TRUE; + } + } + if (!$isInServerList){ + \Drupal::messenger()->addStatus(t('This is the supplier information for all users who have the email ending in @:thing.', [':thing' => $currUserServer])); + } + $supplierProfile = $this->amcprUserService->getSupplierProfile(); + return $this->entityFormBuilder->getForm($supplierProfile, 'dashboard'); + } + + /** + * Checks access for personal profile request. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function profileAccess() { + return AccessResult::allowedIf(!$this->amcprUserService->isProfileIncomplete()); + } + + /** + * Checks access for supplier profile request. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function supplierProfileAccess() { + return AccessResult::allowedIf(!$this->amcprUserService->isProfileIncomplete() && !$this->amcprUserService->isSupplierProfileIncomplete()); + } + + private function completeProfilePrompt() { + return [ + '#theme' => 'amcpr_profile_wizard_prompt', + '#prompt_title' => $this->t("Your profile is incomplete."), + '#prompt_text' => $this->t("Do you wish to complete it now?"), + '#confirm' => [ + 'text' => $this->t('Yes'), + 'path' => Url::fromRoute('amcpr_user_profile.profile_wizard')->toString(), + ], + ]; + } + + private function completeSupplierProfilePrompt() { + return [ + '#theme' => 'amcpr_profile_wizard_prompt', + '#prompt_title' => $this->t("You have not created a supplier profile."), + '#prompt_text' => $this->t("Do you wish to create it now?"), + '#confirm' => [ + 'text' => $this->t('Yes'), + 'path' => Url::fromRoute('amcpr_user_profile.supplier_profile_wizard')->toString(), + ], + ]; + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileUserController.php b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileUserController.php new file mode 100644 index 0000000..c298e2f --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/ProfileUserController.php @@ -0,0 +1,88 @@ +logout(); + + $logout_method = strtolower($logout_method); + if ($logout_method === 'reset') { + return $this->redirect('', [], ['query' => ['method' => 'reset']]); + } + else { + return $this->redirect(''); + } + } + + /** + * {@inheritdoc} + */ + public function userPage() { + return $this->redirect('amcpr_user_profile.account_preferences'); + } + + /** + * {@inheritdoc} + */ + public function resetPassLogin($uid, $timestamp, $hash) { + //@TODO reduce duplicated code by calling parent method. + + // The current user is not logged in, so check the parameters. + $current = REQUEST_TIME; + /** @var \Drupal\user\UserInterface $user */ + $user = $this->userStorage->load($uid); + + // Verify that the user exists and is active. + if ($user === NULL || !$user->isActive()) { + // Blocked or invalid user ID, so deny access. The parameters will be in + // the watchdog's URL for the administrator to check. + throw new AccessDeniedHttpException(); + } + + // Time out, in seconds, until login URL expires. + $timeout = $this->config('user.settings')->get('password_reset_timeout'); + // No time out for first time login. + if ($user->getLastLoginTime() && $current - $timestamp > $timeout) { + $this->messenger()->addError($this->t('You have tried to use a one-time login link that has expired. Please request a new one using the form below.')); + return $this->redirect('user.pass'); + } + elseif ($user->isAuthenticated() && ($timestamp >= $user->getLastLoginTime()) && ($timestamp <= $current) && hash_equals($hash, user_pass_rehash($user, $timestamp))) { + user_login_finalize($user); + $this->logger->notice('User %name used one-time login link at time %timestamp.', ['%name' => $user->getDisplayName(), '%timestamp' => $timestamp]); + $this->messenger()->addStatus($this->t('You have just used your one-time login link. It is no longer necessary to use this link to log in. Please change your password.')); + // Let the user's password be changed without the current password + // check. + $token = Crypt::randomBytesBase64(55); + $_SESSION['pass_reset_' . $user->id()] = $token; + // Clear any flood events for this user. + $this->flood->clear('user.password_request_user', $uid); + return $this->redirect( + 'amcpr_user_profile.account_preferences', + [], + [ + 'query' => ['pass-reset-token' => $token], + 'absolute' => TRUE, + ] + ); + } + + $this->messenger()->addError($this->t('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.')); + return $this->redirect('user.pass'); + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Controller/SupplierProfileWizardController.php b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/SupplierProfileWizardController.php new file mode 100644 index 0000000..598c3dc --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Controller/SupplierProfileWizardController.php @@ -0,0 +1,240 @@ + 'amcpr_user_profile.supplier_profile_wizard', + 2 => 'amcpr_user_profile.supplier_profile_wizard_certifications', + 3 => 'amcpr_user_profile.supplier_profile_wizard_materials', + 4 => 'amcpr_user_profile.supplier_profile_wizard_equipments', + 5 => 'amcpr_user_profile.supplier_profile_wizard_specialities', + 6 => 'amcpr_user_profile.supplier_profile_wizard_finish', + ]; + + /** + * @var \Drupal\amcpr_user_profile\UserProfileServiceInterface + */ + protected $amcprUserService; + + /** + * @var \Drupal\Core\TempStore\PrivateTempStore + */ + protected $tempStore; + + /** + * Constructs a new SupplierProfileWizardController. + * + * @param \Drupal\amcpr_user_profile\UserProfileServiceInterface $user_profile_service + * @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory + * The temp store factory. + */ + public function __construct(UserProfileServiceInterface $user_profile_service, PrivateTempStoreFactory $temp_store_factory) { + // Initialize private members. + $this->currentUser(); + $this->messenger(); + $this->entityTypeManager(); + $this->entityFormBuilder(); + + $this->amcprUserService = $user_profile_service; + $this->tempStore = $temp_store_factory->get('amcpr_supplier_profile_controller'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('amcpr.user.utility'), + $container->get('tempstore.private') + ); + } + + /** + * Exit from the Supplier Profile wizard. + * + * @param bool $show_exit_meesage + * Show a message if the flag is set to TRUE. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function exitWizard($show_exit_meesage = TRUE) { + $this->tempStore->delete('step'); + + if ($show_exit_meesage) { + $this->messenger->addMessage($this->t('You have exited from completing the supplier profile.')); + } + + return $this->redirect(''); + } + + /** + * Step 1: Show supplier profile wizard (begin wizard). + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function business() { + if ($this->amcprUserService->isSupplierProfileIncomplete()) { + return $this->form(1, 'Manufacturing Business Type'); + } + else { + return $this->redirect('amcpr_user_profile.profile_supplier_edit'); + } + } + + /** + * Step 2: Certifications + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function certifications() { + return $this->form(2, 'Manufacturing Certificates'); + } + + /** + * Step 3: Materials + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function materials() { + return $this->form(3, 'Primary Material Experience'); + } + + /** + * Step 4: Equipments + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function equipments() { + return $this->form(4, 'Manufacturing Equipment'); + } + + /** + * Step 5: Specialities + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function specialities() { + return $this->form(5, 'Other Specialities (select all that apply)'); + } + + /** + * Step 6: Profile completed (end wizard). + * + * @param \Symfony\Component\HttpFoundation\Request $request + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function finishWizard(Request $request) { + $step = $this->tempStore->get('step'); + + // Should be at step 5. + if ($step != 5 && $request->getMethod() == 'GET') { + return $this->redirect('amcpr_user_profile.supplier_profile_wizard'); + } + + $this->messenger->addMessage($this->t('Thank you! Your Supplier profile has been saved.')); + $this->amcprUserService->removeSupplierProfileIncompleteFlag(); + $this->amcprUserService->killUserPrimaryNavBlockCache(); + return $this->exitWizard(FALSE); + } + + /** + * Get the route for the next step; + * + * @param int $step + * The current step. + * + * @return string + * Return the route. + */ + public static function getNextRoute($step) { + switch($step) { + case 1: + case 2: + case 3: + case 4: + case 5: + return static::ROUTES[$step + 1]; + default: + return static::ROUTES[1]; + } + } + + /** + * Get the route for the previous step; + * + * @param int $step + * The current step. + * + * @return string + * Return the route. + */ + public static function getPreviousRoute($step) { + switch($step) { + case 2: + case 3: + case 4: + case 5: + return static::ROUTES[$step - 1]; + default: + return ''; + } + } + + /** + * Helper method controller's action methods. + * + * @param int $step + * @param string $form_title + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * Return renderable build array. User maybe redirected on error. + * @throws \Drupal\Core\TempStore\TempStoreException + */ + private function form($step, $form_title) { + + // Get user supplier profile. + $supplierProfile = $this->amcprUserService->getSupplierProfile(TRUE); + + if (!$supplierProfile) { + $this->messenger->addError($this->t('Registration error... Error code 1200-02.')); + return $this->exitWizard(FALSE); + } + + // Save current step. + $this->tempStore->set('step', $step); + + // Display form according the the current step. + $viewMode = 'wizard_step_' . $step; + $form = $this->entityFormBuilder->getForm( + $supplierProfile, + $viewMode, + ['step' => $step] + ); + + return [ + '#title' => $this->t("Let's Complete Your Supplier Profile"), + '#theme' => 'amcpr_profile_wizard_form', + '#form_title' => $this->t($form_title), + '#form' => $form, + ]; + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/EventSubscriber/RequestEventSubscriber.php b/ExchangeMarketPlace/amcpr_user_profile/src/EventSubscriber/RequestEventSubscriber.php new file mode 100644 index 0000000..30de463 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/EventSubscriber/RequestEventSubscriber.php @@ -0,0 +1,86 @@ +account = $account; + } + + /** + * Set message when the user is logged out after password reset. + * + * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event + * The event to process. + */ + public function onKernelRequest(RequestEvent $event) { + $request = $event->getRequest(); + $route_match = RouteMatch::createFromRequest($request); + + // Don't let the user wander around the site if they used + // a one-time login link. + if ($this->account->isAuthenticated()) { + + $resetTokenKey = 'pass_reset_' . $this->account->id(); + if (isset($_SESSION[$resetTokenKey]) && !$request->query->get('pass-reset-token') && $route_match->getRouteName() !== 'user.logout' ) { + + // Redirect user back to password reset form. + \Drupal::messenger()->addMessage(t('You must reset your password.')); + $event->setResponse( + new RedirectResponse(Url::fromRoute( + 'amcpr_user_profile.account_preferences', + [], + [ + 'query' => ['pass-reset-token' => $_SESSION[$resetTokenKey]], + 'absolute' => TRUE, + ] + )->toString()) + ); + + } + } + + if ($route_match->getRouteName() === 'entity.node.canonical') { + $queryParam = $request->query->get('method'); + if ($queryParam === 'reset') { + \Drupal::messenger()->addMessage(t('You have been logged out. Please log back in using the new password.')); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + KernelEvents::REQUEST => 'onKernelRequest', + ]; + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Form/SupplierSettingsForm.php b/ExchangeMarketPlace/amcpr_user_profile/src/Form/SupplierSettingsForm.php new file mode 100644 index 0000000..07fe2aa --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Form/SupplierSettingsForm.php @@ -0,0 +1,64 @@ +config(SupplierSettingsForm::CONFIG_NAME); + + $serverList = $config->get('server_list'); + $form['server_list'] = [ + '#type' => 'textarea', + '#title' => t('Server Listing'), + '#description' => t('Users that use the listed email servers will have the ability to create their own supplier, and will not have a shared supplier for the email server. Separate servers by commas or new line.'), + '#default_value' => ($serverList) ? $serverList : '', + '#format' => 'text', +// '#allowed_formats' => ['basic_html'], + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->config(SupplierSettingsForm::CONFIG_NAME) + ->set('server_list', $form_state->getValue('server_list')) + ->save(); + + parent::submitForm($form, $form_state); + } + +} + diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/AccountPreferenceLinksBlock.php b/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/AccountPreferenceLinksBlock.php new file mode 100644 index 0000000..fc27030 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/AccountPreferenceLinksBlock.php @@ -0,0 +1,81 @@ +amcprUserProfileService = $user_profile_service; + $this->currentUser = $this->amcprUserProfileService->getCurrentUser(); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('amcpr.user.utility') + ); + } + + /** + * {@inheritdoc} + */ + public function build() { + if (!$this->currentUser->isAuthenticated()) { + return []; + } + + $links[] = [ + 'url' => Url::fromRoute('amcpr_user_profile.account_preferences'), + 'title' => $this->t('Sign in & Security'), + 'style' => 'primary' + ]; + + $build = ComponentBuilder::buildCard( + 'Account Preferences', + $links, + ); + $build['#cache'] = ['contexts' => ['user']]; + + return $build; + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/UserPrimaryNavBlock.php b/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/UserPrimaryNavBlock.php new file mode 100644 index 0000000..d2f89d5 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Plugin/Block/UserPrimaryNavBlock.php @@ -0,0 +1,194 @@ +amcprUserProfileService = $user_profile_service; + $this->notificationService = $notification_service; + $this->currentUser = $this->amcprUserProfileService->getCurrentUser(); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('amcpr.user.utility'), + $container->get('amcpr.notification') + ); + } + + /** + * {@inheritdoc} + */ + public function build() { + $is_logged_in = $this->currentUser->isAuthenticated(); + $links = []; + $notifications = []; + $dropdown = []; + + $links['search'] = [ + 'url' => '/search', //@todo replace with actual search route. + 'title' => $this->t('Search'), + 'icon' => 'magnifying-glass', + ]; + + if ($is_logged_in) { + + $notifications = $this->notificationService + ->getUnreadNotifications(static::MAX_NOTIFICATIONS); + + $links['notifications'] = [ + 'url' => Url::fromRoute('amcpr_dashboard.notifications'), + 'title' => $this->t('Notifications'), + 'icon' => 'bell', + 'unread' => $this->notificationService->getUnreadNotificationsCount() + ]; + + $links['dashboard'] = [ + 'url' => Url::fromRoute('amcpr_dashboard.index'), + 'title' => $this->t('Dashboard'), + 'icon' => 'grid-two-up', + ]; + + $dropdown['title'] = t('Me'); + $dropdown['name'] = $this->amcprUserProfileService->getFullName(); + $dropdown['roles'] = $this->amcprUserProfileService->getAmcprRoles(); + $dropdown['profile_links'] = []; + + if ($this->amcprUserProfileService->isProfileIncomplete()) { + $dropdown['profile_links']['personal'] = [ + 'url' => Url::fromRoute('amcpr_user_profile.profile_wizard'), + 'title' => $this->t('Complete Profile'), + 'icon' => 'person', + ]; + } + else { + $dropdown['profile_links']['personal'] = [ + 'url' => Url::fromRoute('amcpr_user_profile.profile'), + 'title' => $this->t('Personal Profile'), + 'icon' => 'person', + ]; + + if ($this->amcprUserProfileService->isSupplierProfileIncomplete()) { + $dropdown['profile_links']['supplier'] = [ + 'url' => Url::fromRoute('amcpr_user_profile.supplier_profile_wizard'), + 'title' => $this->t('Become a Supplier'), + 'icon' => 'globe', + ]; + } + else { + $dropdown['profile_links']['supplier'] = [ + 'url' => Url::fromRoute('amcpr_user_profile.profile_supplier'), + 'title' => $this->t('Supplier Profile'), + 'icon' => 'globe', + ]; + } + } + + $dropdown['link_groups'] = [[ + 'header' => 'Lists', + 'links' => [ + 'favorites_list' => [ + 'url' => Url::fromUri('internal:/lists/favorites'), + 'title' => $this->t('Favorites'), + 'icon' => 'star', + ], + 'offline_list' => [ + 'url' => Url::fromUri('internal:/lists/offline'), + 'title' => $this->t('Offline Exports'), + 'icon' => 'data-transfer-download', + ], + ] + ], [ + 'header' => 'Account', + 'links' => [ + 'preferences' => [ + 'url' => Url::fromRoute('amcpr_user_profile.account_preferences'), + 'title' => $this->t('Preferences'), + 'icon' => 'wrench', + ], + ] + ], [ + 'header' => '', + 'links' =>[ + 'logout' => [ + 'url' => Url::fromRoute('user.logout'), + 'title' => $this->t('Logout'), + 'icon' => 'account-logout', + ], + ] + ]]; + } + else { + $links['register'] = [ + 'url' => Url::fromRoute('user.register'), + 'title' => $this->t('Register'), + 'icon' => 'briefcase', + ]; + $links['login'] = [ + 'url' => Url::fromRoute('user.login'), + 'title' => $this->t('Login'), + 'icon' => 'account-login', + ]; + } + + return [ + '#cache' => ['contexts' => ['user']], + '#theme' => 'amcpr_nav_user_block', + '#is_logged_in' => $is_logged_in, + '#links' => $links, + '#notifications' => $notifications, + '#account_dropdown' => $dropdown, + ]; + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Routing/ProfileRouteSubscriber.php b/ExchangeMarketPlace/amcpr_user_profile/src/Routing/ProfileRouteSubscriber.php new file mode 100644 index 0000000..1c71db2 --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Routing/ProfileRouteSubscriber.php @@ -0,0 +1,71 @@ +get('user.register')) { + $route->setPath('/register'); + } + + if ($route = $collection->get('user.pass')) { + $route->setPath('/login/reset'); + } + + if ($route = $collection->get('user.pass.http')) { + $route->setPath('/login/reset'); + } + + if ($route = $collection->get('user.logout.http')) { + $route->setPath('/logout}'); + } + + if ($route = $collection->get('user.login')) { + $route->setPath('/login'); + } + + if ($route = $collection->get('user.login.http')) { + $route->setPath('/login'); + } + + if ($route = $collection->get('user.reset')) { + $route->setPath('/login/reset/{uid}/{timestamp}/{hash}'); + } + + if ($route = $collection->get('user.reset.form')) { + $route->setPath('/login/reset/{uid}'); + } + + // Change the path and controller for the password reset login route. + if ($route = $collection->get('user.reset.login')) { + $route->setPath('/login/reset/{uid}/{timestamp}/{hash}/login'); + $route->setDefault('_controller', '\Drupal\amcpr_user_profile\Controller\ProfileUserController::resetPassLogin'); + } + + // Change the path and controller for the logout route. + if ($route = $collection->get('user.logout')) { + $route->setPath('/logout/{logout_method}'); + $route->setDefault('_controller', '\Drupal\amcpr_user_profile\Controller\ProfileUserController::customLogout'); + $route->setDefault('logout_method', 'user'); + } + + // Change the controller for the user page route. + if ($route = $collection->get('user.page')) { + $route->setDefault('_controller', '\Drupal\amcpr_user_profile\Controller\ProfileUserController::userPage'); + } + + } + +} diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/Service/UserProfileService.php b/ExchangeMarketPlace/amcpr_user_profile/src/Service/UserProfileService.php new file mode 100644 index 0000000..1bcb33c --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/Service/UserProfileService.php @@ -0,0 +1,438 @@ +entityTypeManager = $entity_type_manager; + $this->currentUser = User::load($current_user->id()); + $this->profileStorage = $this->entityTypeManager->getStorage('profile'); + $this->userStorage = $this->entityTypeManager->getStorage('user'); + } + + /** + * @inheritDoc + */ + public function getCurrentUser() { + return $this->currentUser; + } + + public function getFullName() { + if (!isset($this->personalProfile)) { + $this->personalProfile = $this->getPersonalProfile(); + } + + if (!$this->personalProfile) { + return t('Name Not Set'); + } + + return $this->personalProfile->get('field_first_name')->getString() + . ' ' + . $this->personalProfile->get('field_last_name')->getString(); + } + + /** + * @inheritDoc + */ + public function getAmcprRoles() { + $roles = [t('Designer')]; + if ($this->isSupplier()) { + $roles[] = t('Supplier'); + } + + if ($this->isScreener()) { + $roles[] = t('Screener'); + } + + if ($this->isReviewer()) { + $roles[] = t('Reviewer'); + } + + return $roles; + } + + /** + * @inheritDoc + */ + public function isSupplier() { + return !$this->isSupplierProfileIncomplete(); + } + + /** + * @inheritDoc + */ + public function isScreener() { + return $this->currentUser->hasRole('screener'); + } + + /** + * @inheritDoc + */ + public function isReviewer() { + return $this->currentUser->hasRole('reviewer'); + } + + /** + * @inheritDoc + */ + public function getAllScreeners() { + return $this->getUsersWithRole('screener'); + } + + /** + * @inheritDoc + */ + public function getAllReviewers() { + return $this->getUsersWithRole('reviewer'); + } + + /** + * @inheritDoc + */ + public function getAllAmcprAdministrators() { + return $this->getUsersWithRole('amcpr_admin'); + } + + /** + * @inheritDoc + */ + public function isProfileIncomplete() { + // A flag to indicate incompleteness is set at the time of registration. + // Fetch the flag value. + $isIncompleteFlag = $this->currentUser->get('field_profile_incomplete')->getValue(); + + // See if the flag value is set to ON (numerical 1). + // It means the user has not completed their profile. + if (!empty($isIncompleteFlag) && $isIncompleteFlag[0]['value']) { + return TRUE; + } + + return FALSE; + } + + /** + * @inheritDoc + */ + public function isSupplierProfileIncomplete() { + /** @var \Drupal\profile\Entity\Profile $supplierProfile */ + $supplierProfile = $this->getSupplierProfile(); + if ($supplierProfile) { + // A flag to indicate incompleteness is set at the time of registration. + // Fetch the flag value. + $isIncompleteFlag = $supplierProfile->get('field_profile_incomplete')->getValue(); + // See if the flag value is set to ON (numerical 1). + // It means the user has not completed their profile. + if (!empty($isIncompleteFlag) && $isIncompleteFlag[0]['value']) { + return TRUE; + } + return FALSE; + } + else { + return TRUE; + } + } + + /** + * @inheritDoc + */ + public function removeProfileIncompleteFlag() { + $this->currentUser->set('field_profile_incomplete', 0); + try { + $this->currentUser->save(); + } + catch (EntityStorageException $e) { + // @todo Log the exception to watchdog. + } + } + + /** + * @inheritDoc + */ + public function removeSupplierProfileIncompleteFlag() { + /** @var \Drupal\profile\Entity\Profile $supplierProfile */ + $supplierProfile = $this->getSupplierProfile(); + + $supplierProfile->set('field_profile_incomplete', 0); + try { + $supplierProfile->save(); + } + catch (EntityStorageException $e) { + // @todo Log the exception to watchdog. + } + } + + /** + * @inheritDoc + */ + public function getPersonalProfile($create_new = FALSE) { + return $this->getProfileEntity('personal', $create_new); + } + + /** + * @inheritDoc + */ + public function getPersonalProfileByUid($uid) + { + $entityProperties = [ + 'type' => 'personal', + 'uid' => $uid, + ]; + $profile = $this->profileStorage->loadByProperties($entityProperties); + if (!empty($profile)) { + return reset($profile); + } + } + + /** + * @inheritDoc + */ + public function getSupplierProfile($create_new = FALSE) { + return $this->getProfileEntity('supplier', $create_new); + } + + /** + * Get profile types used for identifying user role. + * + * @param array $excluded + * Array of Profile bundle IDs to exclude. + * + * @return array + * An array of profile type information. + */ + public function getProfileTypes($excluded = ['personal']) { + //$profileBundleInfo = \Drupal::service('entity_type.bundle.info')->getBundleInfo('profile'); + + /** @var \Drupal\profile\Entity\ProfileType[] $profileBundles */ + try { + $profileTypeIds = $this->entityTypeManager + ->getStorage('profile_type') + ->getQuery() + ->condition('id', $excluded, 'NOT IN') + ->execute(); + + $profileBundles = $this->entityTypeManager->getStorage('profile_type') + ->loadMultiple($profileTypeIds); + } + catch (PluginException $e) { + $profileBundles = []; + } + + // Assemble a new associative array with a few properties. + $profileTypes = []; + foreach ($profileBundles as $profileBundle) { + if (method_exists($profileBundle, 'getDescription')) { + $profileTypes[] = [ + 'id' => $profileBundle->id(), + 'label' => $profileBundle->label(), + 'description' => $profileBundle->getDescription(), + ]; + } + else if (property_exists($profileBundle, 'description')) { + $profileTypes[] = [ + 'id' => $profileBundle->id(), + 'label' => $profileBundle->label(), + 'description' => $profileBundle->description, + ]; + } + } + + return $profileTypes; + } + + /** + * @inheritDoc + */ + public function getProfileViewBuilder() { + return $this->entityTypeManager->getViewBuilder('profile'); + } + + /** + * @inheritDoc + */ + public function killUserPrimaryNavBlockCache($uid = NULL) { + if (!$uid) { + $uid = $this->getCurrentUser()->id(); + } + + // Kill the receivers User Block cache -- dirty method @todo use better method. + $cid = 'entity_view:block:userprimarynavblock:[languages:language_interface]=en:[theme]=b4amcpr:[user]=' . $uid; + \Drupal::cache('render')->delete($cid); + } + + public function getSupplierEmails($originalEmail){ + $toReturn = []; + $currUserServer = strtolower(substr($originalEmail, strpos($originalEmail, "@") + 1)); + $configName = (\Drupal\amcpr_user_profile\Form\SupplierSettingsForm::CONFIG_NAME); + $config = \Drupal::config($configName); + $serverListString = $config->get('server_list'); + $serverListArray = preg_split('/(,|\r\n|\n|\r| )/', $serverListString); + $isInServerList = FALSE; + foreach($serverListArray as $indivServer){ + if (strtolower($indivServer) == $currUserServer){ + $isInServerList = TRUE; + } + } + if (!$isInServerList){ + $query = \Drupal::entityQuery('user'); + $uids = $query->execute(); + $users = User::loadMultiple($uids); + $foundProfile = FALSE; + foreach($users as $indivUser){ + $currEmail = $indivUser->getEmail(); + $server = strtolower(substr($currEmail, strpos($currEmail, "@") + 1)); + if ($server == $currUserServer){ + $toReturn[] = $currEmail; + } + } + } + else { + $toReturn[] = $originalEmail; + } + return $toReturn; + } + + /** + * Get the profile entity of a given type that belongs to the current user. + * + * @param string $bundle + * A profile entity type (bundle). + * + * @param bool $create_new + * If set to TRUE, creates a new profile entity when one + * isn't found. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * A profile entity if found; otherwise NULL. + */ + public function getProfileEntity($bundle, $create_new = FALSE, $uid_sent = NULL) { + $uid_to_use = $this->currentUser->id(); + if (!empty($uid_sent)){ + $uid_to_use = $uid_sent; + } + $user_to_use = \Drupal\user\Entity\User::load($uid_to_use); + if ($bundle == 'supplier'){ + $currUserEmail = $user_to_use->getEmail(); + $currUserServer = strtolower(substr($currUserEmail, strpos($currUserEmail, "@") + 1)); + + $configName = (\Drupal\amcpr_user_profile\Form\SupplierSettingsForm::CONFIG_NAME); + $config = \Drupal::config($configName); + $serverListString = $config->get('server_list'); + $serverListArray = preg_split('/(,|\r\n|\n|\r| )/', $serverListString); + $isInServerList = FALSE; + foreach($serverListArray as $indivServer){ + if (strtolower($indivServer) == $currUserServer){ + $isInServerList = TRUE; + } + } + if (!$isInServerList){ + $query = \Drupal::entityQuery('user'); + $uids = $query->execute(); + $users = User::loadMultiple($uids); + $foundProfile = FALSE; + foreach($users as $indivUser){ + if (!$foundProfile){ + $currEmail = $indivUser->getEmail(); + $server = strtolower(substr($currEmail, strpos($currEmail, "@") + 1)); + if ($server == $currUserServer){ + $entityProperties = [ + 'type' => $bundle, + 'uid' => $indivUser->id(), + ]; + $profile = $this->profileStorage->loadByProperties($entityProperties); + if (!empty($profile)) { + $profile = reset($profile); + $isIncompleteFlag = $profile->get('field_profile_incomplete')->getValue(); + if (!empty($isIncompleteFlag) && !$isIncompleteFlag[0]['value']) { + $uid_to_use = $indivUser->id(); + $foundProfile = TRUE; + } + } + } + } + } + } + } + $entityProperties = [ + 'type' => $bundle, + 'uid' => $uid_to_use, + ]; + + $profile = $this->profileStorage->loadByProperties($entityProperties); + if (!empty($profile)) { + return reset($profile); + } + + if ($create_new) { + try { + $profile = $this->profileStorage->create($entityProperties); + $profile->save(); + return $profile; + } + catch (EntityStorageException $e) { + // no code + } + } + + return NULL; + } + + /** + * Get all users with the given role id. + * + * @param string $rid + * The role id. + * + * @return \Drupal\User\UserInterface[] + */ + private function getUsersWithRole($rid) { + /** @var \Drupal\User\UserInterface[] $users */ + $users = $this->userStorage->loadByProperties([ + 'status' => 1, + 'roles' => $rid + ]); + + return $users; + } + +} + diff --git a/ExchangeMarketPlace/amcpr_user_profile/src/UserProfileServiceInterface.php b/ExchangeMarketPlace/amcpr_user_profile/src/UserProfileServiceInterface.php new file mode 100644 index 0000000..3d6ae3d --- /dev/null +++ b/ExchangeMarketPlace/amcpr_user_profile/src/UserProfileServiceInterface.php @@ -0,0 +1,164 @@ +logger = $loggerFactory->get('daftar_stock_api'); + } + + /** + * {@inheritdoc} + */ + public function doSymbolLookup($symbol) { + $jsonData = $this->doGet($symbol, 'OVERVIEW'); + + if (!isset($jsonData['Symbol']) || $jsonData['Symbol'] !== $symbol) { + return []; + } + + return $jsonData; + } + + /** + * @param $symbol + * @param $function + * @param array $params + * @return null + * @throws StockApiException + */ + private function doGet($symbol, $function, array $params = []) { + if (!$symbol) { + throw new StockApiException('Missing symbol'); + } + + if (!$function) { + $this->logger->error('Missing Stock API function.'); + return NULL; + } + + $apiKey = Settings::get(static::SETTINGS_KEY_STOCK_API_KEY, ''); + if (!$apiKey) { + $this->logger->error('Missing Stock API Key.'); + return NULL; + } + + $client = new \GuzzleHttp\Client([ + 'base_uri' => static::API_URL, + 'verify' => FALSE, // ignore invalid ssl + ]); + + $options = [ + 'query' => [ + 'apikey' => $apiKey, + 'function' => strtoupper($function), + 'symbol' => strtoupper($symbol), + ], + ]; + + $response = $client->get(static::API_PATH, $options); + + if ($response->getStatusCode() != '200') { + $this->logger->error('Invalid response received from the Stock API.'); + return NULL; + } + + return json_decode($response->getBody(), TRUE); + } + +} + diff --git a/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooApiService.php b/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooApiService.php new file mode 100644 index 0000000..847ed4b --- /dev/null +++ b/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooApiService.php @@ -0,0 +1,207 @@ +logger = $loggerFactory->get('dsa_yahoo_api'); + } + + /** + * @param string $symbol + * @param string $date + * + * {@inheritdoc} + */ + public function doSymbolLookup($symbol) { + throw new StockApiException("doSymbolLookup has not been implemented."); + } + + /** + * @param string $symbol + * @param string $date + * @param string $type + * + * @return array + * @throws \Drupal\daftar_stock_api\Exception\StockApiException + */ + public function getOptionContract(string $symbol, string $date, string $type, $strike): array { + $data = $this->getOptionContracts($symbol, $date, $type); + + if ($data['error']) { + return $data; + } + + $error = FALSE; + $message = ''; + + $needle = NULL; + foreach ($data['contents'] as $contract) { + if ($contract['strike'] == $strike) { + $needle = $contract; + break; + } + } + + if (!$needle) { + $error = TRUE; + $message = 'Contract with given strike price is not included.'; + } + + return [ + 'error' => $error, + 'message' => $message, + 'contents' => ($error) ? NULL : $needle, + ]; + } + + /** + * @param string $symbol + * @param string $date + * @param string $type + * + * @return array + * @throws \Drupal\daftar_stock_api\Exception\StockApiException + */ + public function getOptionContracts(string $symbol, string $date, string $type): array { + $data = $this->getOptionChain($symbol, $date); + + if ($data['error']) { + return $data; + } + + $error = FALSE; + $message = ''; + + if (empty($data['contents'][$type])) { + $error = TRUE; + $message = "Option for given type ${$type} are not included."; + } + + return [ + 'error' => $error, + 'message' => $message, + 'contents' => ($error) ? NULL : $data['contents'][$type], + ]; + } + + /** + * @param string $symbol + * @param string $date + * + * @return array + * @throws \Drupal\daftar_stock_api\Exception\StockApiException + * @throws \Exception + */ + public function getOptionChain(string $symbol, string $date): array { + $function = sprintf('%s/%s', self::API_ENDPOINT_OPTIONS, $symbol); + $dateTime = new DateTime($date); + $timestamp = $dateTime->format('U'); + + $data = $this->doGet($function, [ + 'date' => $timestamp, + ]); + + $error = FALSE; + $message = ''; + + if ($data['response_code'] !== Response::HTTP_OK) { + $error = TRUE; + $message = 'Response not received.'; + } + elseif ($data['contents']['optionChain']['error']) { + $error = TRUE; + $message = 'Error in response received.'; + } + elseif (empty($data['contents']['optionChain']['result'][0]['expirationDates']) + || empty($data['contents']['optionChain']['result'][0]['strikes'])) { + $error = TRUE; + $message = 'Option chain not included.'; + } + elseif (!$this->findValue($timestamp, $data['contents']['optionChain']['result'][0]['expirationDates'])) { + $error = TRUE; + $message = 'Option chain for given date is not included.'; + } + + return [ + 'error' => $error, + 'message' => $message, + 'contents' => ($error) ? NULL : $data['contents']['optionChain']['result'][0]['options'][0], + ]; + } + + /** + * @param $needle + * @param $haystack + * + * @return bool + */ + private function findValue($needle, $haystack): bool { + $haystackIndex = array_flip($haystack); + return isset($haystackIndex[$needle]); + } + + /** + * @param string $function + * @param array $params + * + * @return array An associative array containing status code and contents + * from the response. + * @throws \Drupal\daftar_stock_api\Exception\StockApiException + */ + private function doGet(string $function, array $params = []): array { + if (!$function) { + throw new StockApiException('Missing required function.'); + } + + if (substr($function, 0, 1) !== '/') { + throw new StockApiException("Function must begin with '/'."); + } + + $client = new \GuzzleHttp\Client([ + 'base_uri' => static::API_URL, + 'verify' => FALSE, // ignore invalid ssl + ]); + + $options = [ + 'allow_redirects' => FALSE, + 'query' => $params, + ]; + + $response = $client->get(self::API_PATH . $function, $options); + if ($response->getStatusCode() != Response::HTTP_OK) { + $this->logger->error('Invalid response received from Yahoo Finance API. Received status code: ' . $response->getStatusCode()); + } + + return [ + 'response_code' => $response->getStatusCode(), + 'contents' => json_decode($response->getBody()->getContents(), TRUE), + ]; + + } + +} + diff --git a/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooParserService.php b/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooParserService.php new file mode 100644 index 0000000..634c794 --- /dev/null +++ b/StockPortfolioManagement/api/daftar_stock_api/src/Service/YahooParserService.php @@ -0,0 +1,110 @@ +logger = $loggerFactory->get('daftar_stock_api_yahoo'); + } + + /** + * {@inheritdoc} + */ + public function doSymbolLookup($symbol) { + throw new StockApiException("doSymbolLookup has not been implemented."); + } + + /** + * @param $symbol + * + * @return string|null + */ + public function getPrice($symbol): ?string { + $htmlData = $this->doGet($symbol, static::API_QUOTE_FUNCTION . '/' . $symbol); + if (!$htmlData) { + return NUll; + } + + $doc = new DOMDocument(); + $doc->loadHTML($htmlData); + + $quoteHeaderInfo = $doc->getElementById('quote-header-info'); + if (!$quoteHeaderInfo) { + $this->logger->error('Unable to parse HTML response. Price not found. Symbol: ' . $symbol); + return NULL; + } + + $finStreamers = $quoteHeaderInfo->getElementsByTagName('fin-streamer'); + if ($finStreamers->count() <= 0) { + $this->logger->error('Missing element in HTML response.'); + } + + /** @var \DOMElement $elem */ + foreach ($finStreamers as $elem) { + $dataField = $elem->getAttribute('data-field'); + if ($dataField !== 'regularMarketPrice') { + continue; + } + + return $elem->getAttribute('value'); + } + + $this->logger->error('Missing element with price in HTML response.'); + return NULL; + } + + /** + * @param $symbol + * @param $function + * @param array $params + * @return string|null + */ + private function doGet($symbol, $function, array $params = []) { + if (!$function) { + $this->logger->error('Missing required function.'); + return NULL; + } + + $client = new \GuzzleHttp\Client([ + 'base_uri' => static::API_URL, + 'verify' => FALSE, // ignore invalid ssl + ]); + + $options = [ + 'allow_redirects' => FALSE, + 'query' => [ + 'p' => strtoupper($symbol), + ], + ]; + + $response = $client->get($function, $options); + if ($response->getStatusCode() != '200') { + $this->logger->error('Invalid response received from Yahoo. Received status code: ' . $response->getStatusCode()); + return NULL; + } + + return $response->getBody()->getContents(); + } + +} + diff --git a/StockPortfolioManagement/api/daftar_stock_api/src/StockApiServiceInterface.php b/StockPortfolioManagement/api/daftar_stock_api/src/StockApiServiceInterface.php new file mode 100644 index 0000000..0d4f2fe --- /dev/null +++ b/StockPortfolioManagement/api/daftar_stock_api/src/StockApiServiceInterface.php @@ -0,0 +1,19 @@ +hasKey('label')) { + $fields[$entity_type->getKey('label')] = BaseFieldDefinition::create('string') + ->setLabel(t('Label')) + ->setSetting('max_length', 100) + ->setRequired(TRUE); + } + + if ($entity_type->hasKey('created')) { + $fields[$entity_type->getKey('created')] = BaseFieldDefinition::create('created') + ->setLabel(t('Created')) + ->setDescription(t('The time that the entity was created.')) + ->setRequired(TRUE); + } + + if ($entity_type->hasKey('changed')) { + $fields[$entity_type->getKey('changed')] = BaseFieldDefinition::create('changed') + ->setLabel(t('Changed')) + ->setDescription(t('The time that the entity was last edited.')) + ->setRequired(TRUE); + } + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/DividendInterface.php b/StockPortfolioManagement/daftar/src/DividendInterface.php new file mode 100644 index 0000000..42778c6 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/DividendInterface.php @@ -0,0 +1,35 @@ + 'Cash', + ]; + + public const TAX_RATES = [ + 'qualified' => 'Qualified', + 'ordinary' => 'Ordinary', + 'other' => 'Other', + ]; + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Chopda.php b/StockPortfolioManagement/daftar/src/Entity/Chopda.php new file mode 100644 index 0000000..5099cd7 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Chopda.php @@ -0,0 +1,78 @@ +setLabel(t('Portfolio ID')); + $fields['uuid']->setLabel(t('Portfolio UUID')); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function toArray() { + return[ + 'cid' => $this->id(), + 'uuid' => $this->uuid(), + 'kid' => $this->getKhatuId(), + 'label' => $this->label(), + 'created' => $this->getCreatedTime(), + 'changed' => $this->getChangedTime(), + ]; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Helper/EntityCreatedInterface.php b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityCreatedInterface.php new file mode 100644 index 0000000..4b8136d --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityCreatedInterface.php @@ -0,0 +1,32 @@ +get('created')->value; + } + + /** + * Sets the creation date of the entity. + * + * @param int $timestamp + * The timestamp of when the entity was created. + * + * @return $this + * The class instance that this method is called on. + */ + public function setCreatedTime($timestamp) { + $this->set('created', $timestamp); + return $this; + } + +} diff --git a/StockPortfolioManagement/daftar/src/Entity/Helper/EntityKhatuInterface.php b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityKhatuInterface.php new file mode 100644 index 0000000..28b0378 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityKhatuInterface.php @@ -0,0 +1,56 @@ +getClass(), EntityKhatuInterface::class)) { + throw new UnsupportedEntityTypeDefinitionException( + sprintf('The entity type %s does not implement \Drupal\daftar\Entity\Helper\EntityReferencedInterface.', $entity_type->id()) + ); + } + if (!$entity_type->hasKey(EntityKhatuInterface::KEY)) { + throw new UnsupportedEntityTypeDefinitionException( + sprintf('The entity type %s does not have a "%s" entity key.', $entity_type->id(), EntityKhatuInterface::KEY) + ); + } + + return [ + $entity_type->getKey(EntityKhatuInterface::KEY) => BaseFieldDefinition::create('entity_reference') + ->setLabel(t(EntityKhatuInterface::LABEL)) + ->setDescription(t('The {daftar_khatao}.kid of the account.')) + ->setSetting('target_type', EntityKhatuInterface::TARGET_ENTITY_TYPE) + ->setRequired(TRUE), + ]; + } + + /** + * {@inheritdoc} + */ + public function getKhatu() { + $key = $this->getEntityType()->getKey(EntityKhatuInterface::KEY); + return $this->get($key)->entity; + } + + /** + * {@inheritdoc} + */ + public function setKhatu(KhatuInterface $entity) { + $key = $this->getEntityType()->getKey(EntityKhatuInterface::KEY); + $this->set($key, $entity); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getKhatuId() { + return $this->getEntityKey(EntityKhatuInterface::KEY); + } + + /** + * {@inheritdoc} + */ + public function setKhatuId($khatu_id) { + $key = $this->getEntityType()->getKey(EntityKhatuInterface::KEY); + $this->set($key, $khatu_id); + + return $this; + } + +} diff --git a/StockPortfolioManagement/daftar/src/Entity/Helper/EntityReferencedInterface.php b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityReferencedInterface.php new file mode 100644 index 0000000..c14ea9c --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Helper/EntityReferencedInterface.php @@ -0,0 +1,53 @@ +getClass(), EntityReferencedInterface::class)) { + throw new UnsupportedEntityTypeDefinitionException('The entity type ' . $entity_type->id() . ' does not implement \Drupal\daftar\Entity\Helper\EntityReferencedInterface.'); + } + if (!$entity_type->hasKey('reference')) { + throw new UnsupportedEntityTypeDefinitionException('The entity type ' . $entity_type->id() . ' does not have a "reference" entity key.'); + } + + return [ + $entity_type->getKey('reference') => BaseFieldDefinition::create('entity_reference') + ->setLabel(new TranslatableMarkup('Reference ID')) + ->setSetting('target_type', $target_type) + ->setRequired(TRUE), + ]; + } + + /** + * {@inheritdoc} + */ + public function getReference() { + $key = $this->getEntityType()->getKey('reference'); + return $this->get($key)->entity; + } + + /** + * {@inheritdoc} + */ + public function setReference(EntityInterface $entity) { + $key = $this->getEntityType()->getKey('reference'); + $this->set($key, $entity); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getReferenceId() { + return $this->getEntityKey('reference'); + } + + /** + * {@inheritdoc} + */ + public function setReferenceId($entity_id) { + $key = $this->getEntityType()->getKey('reference'); + $this->set($key, $entity_id); + + return $this; + } + +} diff --git a/StockPortfolioManagement/daftar/src/Entity/Khatu.php b/StockPortfolioManagement/daftar/src/Entity/Khatu.php new file mode 100644 index 0000000..a791f1c --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Khatu.php @@ -0,0 +1,99 @@ +setLabel(t('Account ID')); + $fields['uuid']->setLabel(t('Account UUID')); + $fields['type']->setLabel(t('Account Type')); + + $fields['uid'] + ->setDescription(t('The {user}.uid of the account owner.')); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function isBank() { + return $this->bundle() === 'bank'; + } + + /** + * {@inheritdoc} + */ + public function isBrokerage() { + return $this->bundle() === 'brokerage'; + } + + /** + * {@inheritdoc} + */ + public function toArray() { + return[ + 'kid' => $this->id(), + 'uuid' => $this->uuid(), + 'type' => $this->bundle(), + 'uid' => $this->getOwnerId(), + 'label' => $this->label(), + 'created' => $this->getCreatedTime(), + 'changed' => $this->getChangedTime(), + ]; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Sodo/Dividend.php b/StockPortfolioManagement/daftar/src/Entity/Sodo/Dividend.php new file mode 100644 index 0000000..92c268a --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Sodo/Dividend.php @@ -0,0 +1,87 @@ +setLabel(t('Date Paid')); + $fields['date']->setDescription(t('Date when this dividend was paid.')); + + $fields['ex_date'] = BaseFieldDefinition::create('datetime') + ->setLabel(t('Execution Date')) + ->setDescription(t('Date when this dividend was executed.')) + ->setSetting('datetime_type', 'date'); + + $fields['symbol'] = BaseFieldDefinition::create('string') + ->setLabel(t('Symbol')) + ->setSetting('max_length', 15) + ->setRequired(TRUE); + + $fields['quantity'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Number of shares')) + ->setDescription(t('Number of shares on which the dividend was paid out.')) + ->setSetting('precision', 12) + ->setSetting('scale', 4) + ->setRequired(TRUE); + + $fields['tax_rate'] = BaseFieldDefinition::create('string') + ->setLabel(t('Tax Rate Classification')) + ->setSetting('max_length', 15) + ->setDescription(t('Tax rate classification of the dividend (e.g. qualified and ordinary).')); + + $fields['auto_created'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('Auto Created')) + ->setDescription(t('A boolean flag indicating whether the dividend was automatically added.')) + ->setDefaultValue(FALSE); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Sodo/Order.php b/StockPortfolioManagement/daftar/src/Entity/Sodo/Order.php new file mode 100644 index 0000000..207436a --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Sodo/Order.php @@ -0,0 +1,100 @@ +setLabel($entity_type->getBundleLabel()) + ->setRequired(TRUE) + ->setReadOnly(TRUE); + + $fields['symbol'] = BaseFieldDefinition::create('string') + ->setLabel(t('Symbol')) + ->setSetting('max_length', 15) + ->setRequired(TRUE); + + $fields['quantity'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Quantity')) + ->setDescription(t('Number of shares exchanged.')) + ->setSetting('precision', 10) + ->setSetting('scale', 4) + ->setRequired(TRUE); + + $fields['on_hand'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Quantity On Hand')) + ->setDescription(t('Number of shares currently on hand.')) + ->setSetting('precision', 10) + ->setSetting('scale', 4); + + $fields['cps'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Cost per Share')) + ->setDescription(t('Price paid per share (computed).')) + ->setSetting('precision', 10) + ->setSetting('scale', 4) + ->setComputed(TRUE); + + $fields['commission'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Commission')) + ->setDescription(t('Commission charged by the brokerage.')) + ->setSetting('precision', 10) + ->setSetting('scale', 4) + ->setDefaultValue(0); + + $fields['fees'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Fees')) + ->setDescription(t('Fees charged for the transaction.')) + ->setSetting('precision', 10) + ->setSetting('scale', 4) + ->setDefaultValue(0); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Sodo/SodoEntityBase.php b/StockPortfolioManagement/daftar/src/Entity/Sodo/SodoEntityBase.php new file mode 100644 index 0000000..a884fe8 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Sodo/SodoEntityBase.php @@ -0,0 +1,70 @@ +setLabel(t('Transaction Date')) + ->setDescription(t('Date when this transaction took place.')) + ->setSetting('datetime_type', 'date') + ->setRequired(TRUE); + + $fields['amount'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Amount')) + ->setDescription(t('Total transaction amount.')) + ->setSetting('precision', 12) + ->setSetting('scale', 4) + ->setRequired(TRUE); + + $fields['notes'] = BaseFieldDefinition::create('string') + ->setLabel(t('Notes')) + ->setDescription(t('Short descriptive text for future references.')) + ->setSetting('max_length', 255); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/Entity/Sodo/Transfer.php b/StockPortfolioManagement/daftar/src/Entity/Sodo/Transfer.php new file mode 100644 index 0000000..7c122e0 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/Entity/Sodo/Transfer.php @@ -0,0 +1,59 @@ +setDescription(t('The account from/to which the amount was transferred.')); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar/src/KhatuInterface.php b/StockPortfolioManagement/daftar/src/KhatuInterface.php new file mode 100644 index 0000000..70a403e --- /dev/null +++ b/StockPortfolioManagement/daftar/src/KhatuInterface.php @@ -0,0 +1,49 @@ + 'Bank', + 'brokerage' => 'Brokerage', + 'ira_tradition' => 'Traditional IRA', + 'ira_rollover' => 'Rollover IRA', + 'ira_roth' => 'Roth IRA', + ]; + + /** + * @return bool + * Returns TRUE if this entity is of bank type. + * FALSE otherwise. + */ + public function isBank(); + + /** + * @return bool + * Returns TRUE if this entity is of brokerage type. + * FALSE otherwise. + */ + public function isBrokerage(); + +} + diff --git a/StockPortfolioManagement/daftar/src/OrderInterface.php b/StockPortfolioManagement/daftar/src/OrderInterface.php new file mode 100644 index 0000000..4ace664 --- /dev/null +++ b/StockPortfolioManagement/daftar/src/OrderInterface.php @@ -0,0 +1,36 @@ + 'Market', + 'lmt' => 'Limit', + 'stp' => 'Stop', + 'stplmt' => 'Stop Limit', + ]; + + public const ACTIONS = [ + 'buy' => 'Buy', + 'sell' => 'Sell', + ]; + +} + diff --git a/StockPortfolioManagement/daftar/src/TransferInterface.php b/StockPortfolioManagement/daftar/src/TransferInterface.php new file mode 100644 index 0000000..ff15b2c --- /dev/null +++ b/StockPortfolioManagement/daftar/src/TransferInterface.php @@ -0,0 +1,24 @@ +formBuilder = $formBuilder; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('form_builder') + ); + } + + /** + * Returns an AJAX response with the requested form. + * @param string $type + * @param int $width + * @return AjaxResponse + */ + public function launchModal($type, $width = 600) { + + $form = self::getFormInfo($type); + if (NULL === $form) { + throw new NotFoundHttpException(); + } + + $response = new AjaxResponse(); + $modalForm = $this->formBuilder->getForm($form['class'], $type, ContentBaseForm::DISPLAY_MODE_MODAL); + $response->addCommand(new OpenModalDialogCommand($form['title'], $modalForm, ['width' => $width])); + return $response; + } + + /** + * Map id to name of a class that implements the form. + * @param string $type + * @return array|null + */ + private static function getFormInfo($type) { + switch($type) { + case 'brokerage': + return [ + 'class' => 'Drupal\daftar_content\Form\AccountForm', + 'title' => 'Add a Brokerage Account', + ]; + case 'bank': + return [ + 'class' => 'Drupal\daftar_content\Form\AccountForm', + 'title' => 'Add a Bank Account', + ]; + default: + return NULL; + } + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Entity/Dividend.php b/StockPortfolioManagement/daftar_content/src/Entity/Dividend.php new file mode 100644 index 0000000..00f1d0f --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Entity/Dividend.php @@ -0,0 +1,91 @@ +setLabel(t('Date Paid')); + $fields['transaction_date']->setDescription(t('Date when this dividend was paid.')); + + $fields['execution_date'] = BaseFieldDefinition::create('datetime') + ->setLabel(t('Execution Date')) + ->setDescription(t('Date when this dividend was executed.')) + ->setSetting('datetime_type', 'date'); + + $fields['qualified'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('Qualified dividend')) + ->setDescription(t('A boolean flag indicating qualified dividend classification.')) + ->setDefaultValue(TRUE); + + $fields['auto_imported'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('Auto Imported')) + ->setDescription(t('A boolean flag indicating whether the dividend was automatically imported.')) + ->setDefaultValue(FALSE); + + // Entity reference field, holds the reference to a taxonomy term object. + $fields['symbol'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Symbol')) + ->setDescription(t('Stock ticker.')) + ->setSetting('target_type', 'taxonomy_term') + ->setSetting('handler', 'default') + ->setSetting('handler_settings', [ + 'target_bundles' => [ + 'symbols' => 'symbols', + ], + ]) + ->setReadOnly(TRUE) + ->setRequired(TRUE); + + $fields['quantity'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Number of shares')) + ->setDescription(t('Number of shares on which dividend was paid.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setRequired(TRUE); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Entity/Interfaces/TransactionEntityAccountInterface.php b/StockPortfolioManagement/daftar_content/src/Entity/Interfaces/TransactionEntityAccountInterface.php new file mode 100644 index 0000000..98e2d1d --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Entity/Interfaces/TransactionEntityAccountInterface.php @@ -0,0 +1,58 @@ +setLabel(t('Order Action')) + ->setDescription(t('The action being performed by this order.')) + ->setSetting('size', 'tiny') + ->setSetting('unsigned', TRUE) + ->setRequired(TRUE); + + $fields['type'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Order Type')) + ->setDescription(t('The type of order being executed.')) + ->setSetting('size', 'tiny') + ->setSetting('unsigned', TRUE); + + // Entity reference field, holds the reference to a taxonomy term object. + $fields['symbol'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Symbol')) + ->setDescription(t('Stock ticker.')) + ->setSetting('target_type', 'taxonomy_term') + ->setSetting('handler', 'default') + ->setSetting('handler_settings', [ + 'target_bundles' => [ + 'symbols' => 'symbols', + ], + ]) + ->setReadOnly(TRUE) + ->setRequired(TRUE); + + $fields['quantity'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Quantity')) + ->setDescription(t('Number of shares exchanged.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setRequired(TRUE); + + $fields['on_hand'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Quantity On Hand')) + ->setDescription(t('Number of shares currently on hand.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2); + + $fields['pps'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Price per Share')) + ->setDescription(t('Price paid per share (computed).')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setComputed(TRUE); + + $fields['commission'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Commission')) + ->setDescription(t('Commission charged by the brokerage.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setDefaultValue(0); + + $fields['fees'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Fees')) + ->setDescription(t('Fees charged for the transaction.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setDefaultValue(0); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Entity/Traits/TransactionAccountTrait.php b/StockPortfolioManagement/daftar_content/src/Entity/Traits/TransactionAccountTrait.php new file mode 100644 index 0000000..5e475c4 --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Entity/Traits/TransactionAccountTrait.php @@ -0,0 +1,108 @@ +getClass(), TransactionEntityAccountInterface::class)) { + throw new UnsupportedEntityTypeDefinitionException('The entity type ' . $entity_type->id() . ' does not implement \Drupal\daftar_content\Entity\Interfaces\TransactionEntityAccountInterface.'); + } + if (!$entity_type->hasKey('account')) { + throw new UnsupportedEntityTypeDefinitionException('The entity type ' . $entity_type->id() . ' does not have an "account" entity key.'); + } + + return [ + // Account field of the transaction. + // Entity reference field, holds the reference to a account node object. + $entity_type->getKey('account') => BaseFieldDefinition::create('entity_reference') + ->setLabel(new TranslatableMarkup('Account ID')) + ->setDescription(new TranslatableMarkup('The id of the account node this transaction is associated with.')) + ->setSetting('target_type', 'node') + ->setSetting('handler', 'default') + ->setSetting('handler_settings', [ + 'target_bundles' => [ + 'account' => 'account', + ], + ]) + ->setReadOnly(TRUE), + ]; + } + + /** + * Returns the entity's account node entity. + * + * @return \Drupal\node\NodeInterface + * The account node entity. + */ + public function getAccount() { + $key = $this->getEntityType()->getKey('account'); + return $this->get($key)->entity; + } + + /** + * Sets the entity's account node entity. + * + * @param \Drupal\node\NodeInterface $account + * The account node entity. + * + * @return $this + */ + public function setAccount(NodeInterface $account) { + $key = $this->getEntityType()->getKey('account'); + $this->set($key, $account); + + return $this; + } + + /** + * Returns the entity's account node ID. + * + * @return int|null + * The account node ID, or NULL in case the account ID field has not been set on + * the entity. + */ + public function getAccountId() { + return $this->getEntityKey('account'); + } + + /** + * Sets the entity's account node ID. + * + * @param int $aid + * The account node id. + * + * @return $this + */ + public function setAccountId($aid) { + $key = $this->getEntityType()->getKey('account'); + $this->set($key, $aid); + + return $this; + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Entity/TransactionEntityBase.php b/StockPortfolioManagement/daftar_content/src/Entity/TransactionEntityBase.php new file mode 100644 index 0000000..0891e95 --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Entity/TransactionEntityBase.php @@ -0,0 +1,76 @@ +setLabel(t('Transaction Date')) + ->setDescription(t('Date when this transaction took place.')) + ->setSetting('datetime_type', 'date') + ->setRequired(TRUE); + + $fields['amount'] = BaseFieldDefinition::create('decimal') + ->setLabel(t('Amount')) + ->setDescription(t('Total transaction amount.')) + ->setSetting('precision', 10) + ->setSetting('scale', 2) + ->setRequired(TRUE); + + if ($entity_type->hasKey('notes')) { + $notesKey = $entity_type->getKey('notes'); + $fields[$notesKey] = BaseFieldDefinition::create('string') + ->setLabel(t('Notes')) + ->setDescription(t('Short descriptive text for future references.')) + ->setSetting('max_length', 255); + } + + if ($entity_type->hasKey('changed')) { + $changedKey = $entity_type->getKey('changed'); + $fields[$changedKey] = BaseFieldDefinition::create('changed') + ->setLabel(t('Last Updated')) + ->setDescription(t('The time that the transaction was last updated.')) + ->setReadOnly(TRUE); + } + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Entity/Transfer.php b/StockPortfolioManagement/daftar_content/src/Entity/Transfer.php new file mode 100644 index 0000000..816b61a --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Entity/Transfer.php @@ -0,0 +1,70 @@ +hasKey('account')) { + $key = $entity_type->getKey('account'); + $fields[$key]->setLabel(t('To')); + $fields[$key]->setDescription(t('The Account to which the amount was deposited.')); + } + + // Account field of the transaction. + // Entity reference field, holds the reference to a account node object. + $fields['account_withdrawn'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('From')) + ->setDescription(t('The Account from which the amount was withdrawn.')) + ->setSetting('target_type', 'node') + ->setSetting('handler', 'default') + ->setSetting('handler_settings', [ + 'target_bundles' => [ + 'account' => 'account', + ], + ]) + ->setRequired(TRUE) + ->setReadOnly(TRUE); + + return $fields; + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Form/AccountForm.php b/StockPortfolioManagement/daftar_content/src/Form/AccountForm.php new file mode 100644 index 0000000..c761dff --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Form/AccountForm.php @@ -0,0 +1,196 @@ +set('type', $type); + $fields = $this->getFieldDefinitions('account'); + + // Get anything we need from the base class. + $form = parent::buildForm($form, $form_state, $display_mode); + + $form['title'] = [ + '#type' => 'textfield', + '#title' => $fields['title']->getLabel(), + '#description' => $this->t('%title for the account. Do NOT enter your account number.', ['%title' => $fields['title']->getLabel()]), + '#maxlength' => self::MAX_LENGTH_ALIAS, + '#pattern' => '^[a-zA-Z0-9 ]{5,}$', + '#attributes' => ['title' => $this->t('Must be at least five characters long. Only alphanumeric and space characters are supported.')], + '#required' => TRUE, + ]; + + switch($display_mode) { + case self::DISPLAY_MODE_EMBEDDED: + break; + + case self::DISPLAY_MODE_DEFAULT: + default: + // set page title + $form['#title'] = ($type === 'brokerage') + ? $this->t('Add a Brokerage Account') + : $this->t('Add a Bank Account'); + + // no break - intentionally left off + + case self::DISPLAY_MODE_MODAL: + if (isset($fields['field_notes'])) { + $form['field_notes'] = [ + '#type' => 'textarea', + '#title' => $fields['field_notes']->getLabel(), + '#description' => $this->t('Max characters allowed: %max', ['%max' => self::MAX_LENGTH_NOTE]), + '#maxlength' => self::MAX_LENGTH_NOTE, + '#rows' => 2, + '#resizable' => 'none', + ]; + } + + } + + return $form; + } + + /** + * @inheritDoc + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + + $type = $form_state->get('type'); + $title = trim($form_state->getValue('title')); + + // @todo change to custom exception + if($type !== 'brokerage' && $type !== 'bank') { + throw new NotFoundHttpException(); + } + + $titleLabel = $form['title']['#title']; + if (strlen($title) < self::MIN_LENGTH_ALIAS) { + $form_state->setErrorByName('title', $this->t( + '%label must be at least %min characters long.', + ['%label' => $titleLabel, '%min' => self::MIN_LENGTH_ALIAS] + )); + } + elseif (preg_match('/[^a-zA-Z0-9 $]/', $title)) { + $form_state->setErrorByName('title', $this->t( + '%label contains invalid characters.', + ['%label' => $titleLabel] + )); + } + elseif ($this->isDuplicate($title)) { + $form_state->setErrorByName('title', $this->t( + 'An account already exists under your portfolio with given alias.', + ['%label' => $titleLabel] + )); + } + + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + $type = $form_state->get('type'); + $title = trim($form_state->getValue('title')); + $notes = substr(trim($form_state->getValue('field_notes')), 0, self::MAX_LENGTH_NOTE); + + $node = $this->nodeStorage->create([ + 'type' => 'account', + 'status' => TRUE, + 'uid' => $this->currentUser()->id(), + 'title' => $title, + 'field_account_status' => TRUE, + 'field_account_type' => ($type == 'brokerage') ? 'br' : 'ba', + 'field_notes' => $notes + ]); + + //@TODO improve for AJAX and embedded form. + // Remove message. + try { + $saveOperation = $node->save(); + if ($saveOperation == SAVED_NEW) { + $this->messenger()->addMessage( + $this->t('Created %type account: %title', [ + '%type' => $form_state->get('type'), + '%title' => $title, + ]), + MessengerInterface::TYPE_STATUS + ); + $form_state->set('account_node', $node); + return; + } + } + catch (EntityStorageException $e) { + $this->messenger()->addMessage( + $this->t('Unable to create account. Please try again.'), + MessengerInterface::TYPE_ERROR + ); + } + + $form_state->set('account_node', NULL); + } + + /** + * @inheritDoc + */ + public function submitFormAjaxAdd(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + $response->addCommand(new OpenModalDialogCommand("Success!", 'The modal form has been submitted.')); + return $response; + } + + protected function isDuplicate($title) { + $title = strtolower($title); + $isDuplicate = FALSE; + $results = $this->getAccountNids(); + + if (!empty($results)) { + $nodes = $this->nodeStorage->loadMultiple($results); + foreach ($nodes as $nid => $node) { + if ( $title === strtolower(trim($node->label())) ) { + $isDuplicate = TRUE; + break; + } + } + } + + return $isDuplicate; + } + + protected function getAccountNids() { + return $this->nodeStorage->getQuery() + ->condition('status', 1) + ->condition('type', 'account') + ->condition('uid', $this->currentUser()->id()) + ->execute(); + } + +} + diff --git a/StockPortfolioManagement/daftar_content/src/Form/ContentBaseForm.php b/StockPortfolioManagement/daftar_content/src/Form/ContentBaseForm.php new file mode 100644 index 0000000..fe6a53a --- /dev/null +++ b/StockPortfolioManagement/daftar_content/src/Form/ContentBaseForm.php @@ -0,0 +1,114 @@ +entityFieldManager = $entity_field_manager; + $this->nodeStorage = $node_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_field.manager'), + $container->get('entity_type.manager')->getStorage('node') + ); + } + + /** + * Callback for AJAX submit. Deriving class must implement this method. + * + * @param array $form + * An associative array containing the structure of the form. + * @param FormStateInterface $form_state + * The current state of the form. + * @return array + * Renderable array (the box element) + */ + public abstract function submitFormAjaxAdd(array &$form, FormStateInterface $form_state); + + /** + * @inheritDoc + */ + public function buildForm(array $form, FormStateInterface $form_state, $display_mode = self::DISPLAY_MODE_DEFAULT) { + + $form['actions'] = [ + '#type' => 'actions', + '#weight' => 100, + ]; + + switch($display_mode) { + case self::DISPLAY_MODE_EMBEDDED: + unset($form['actions']); + break; + + case self::DISPLAY_MODE_MODAL: + $form['status_messages'] = [ + '#type' => 'status_messages', + '#weight' => -100, + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Add'), + '#attributes' => [ + 'class' => [ + 'use-ajax', + ], + ], + '#ajax' => [ + 'callback' => [$this, 'submitFormAjaxAdd'], + 'event' => 'click', + ], + ]; + + $form['#attached']['library'][] = 'core/drupal.dialog.ajax'; + + break; + + case self::DISPLAY_MODE_DEFAULT: + default: + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Add'), + ]; + } + + return $form; + } + + /** + * @param string $node_bundle + * The bundle (e.g. account, order, dividend & etc). + * @return \Drupal\Core\Field\FieldDefinitionInterface[] + */ + protected function getFieldDefinitions($node_bundle) { + return $this->entityFieldManager->getFieldDefinitions('node', $node_bundle); + } + +} + diff --git a/StockPortfolioManagement/daftar_import/daftar_import.info.yml b/StockPortfolioManagement/daftar_import/daftar_import.info.yml new file mode 100644 index 0000000..b70040b --- /dev/null +++ b/StockPortfolioManagement/daftar_import/daftar_import.info.yml @@ -0,0 +1,10 @@ +name: Dafter Import CSV +description: Allows user to import transactions from a CSV file. +core: 8.x +type: module +package: Daftar +core_version_requirement: ^8 || ^9 +version: 1.0.0 +dependencies: + - daftar_content:daftar_content + - daftar_stock_api:daftar_stock_api diff --git a/StockPortfolioManagement/daftar_import/daftar_import.routing.yml b/StockPortfolioManagement/daftar_import/daftar_import.routing.yml new file mode 100644 index 0000000..59c57c0 --- /dev/null +++ b/StockPortfolioManagement/daftar_import/daftar_import.routing.yml @@ -0,0 +1,15 @@ +daftar_import.csv_orders: + path: '/import/csv/orders' + defaults: + _form: '\Drupal\daftar_import\Form\ImportCsvOrdersForm' + _title: 'Import Orders from CSV' + requirements: + _role: 'authenticated' + +daftar_import.csv_transfers: + path: '/import/csv/transfers' + defaults: + _form: '\Drupal\daftar_import\Form\ImportCsvTransfersForm' + _title: 'Import Transfers from CSV' + requirements: + _role: 'authenticated' diff --git a/StockPortfolioManagement/daftar_import/src/Exception/CsvDataException.php b/StockPortfolioManagement/daftar_import/src/Exception/CsvDataException.php new file mode 100644 index 0000000..15fb316 --- /dev/null +++ b/StockPortfolioManagement/daftar_import/src/Exception/CsvDataException.php @@ -0,0 +1,10 @@ +nodeStorage = $node_storage; + $this->termStorage = $term_storage; + $this->fileStorage = $file_storage; + $this->stockApi = $stock_api; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $form = new static( + $container->get('entity_type.manager')->getStorage('node'), + $container->get('entity_type.manager')->getStorage('taxonomy_term'), + $container->get('entity_type.manager')->getStorage('file'), + $container->get('daftar.stock_api.alphavantage') + ); + + $form->accountForm = AccountForm::create($container); + return $form; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $brokerages = $this->getBrokerageOptions(); + $maxCsvSize = intval($this->getMaxCsvSize()); + + $form['description'] = [ + '#type' => 'item', + '#markup' => 'Override or unset this element.', + '#weight' => '-20', + ]; + + if (!empty($brokerages)) { + $form['brokerage'] = [ + '#type' => 'select', + '#title' => $this->t('Brokerage Account'), + '#options' => $brokerages, + '#required' => TRUE, + '#validated' => TRUE, // Setting it to TRUE stops default validation. Must validate manually. + '#weight' => -15, + ]; + } + else { + $form['brokerage'] = [ + '#type' => 'hidden', + '#value' => 'new' + ]; + } + + $form += $this->buildAccountForm($form_state); + if (isset($form['title'])) { + unset($form['title']['#required']); + + $form['title']['#title'] = $this->t('New Account Alias'); + $form['title']['#weight'] = -10; + $form['title']['#states'] = [ + 'visible' => [ + ':input[name="brokerage"]' => ['value' => 'new'], + ], + 'required' => [ + ':input[name="brokerage"]' => ['value' => 'new'], + ] + ]; + } + + $form['csv_file'] = [ + '#type' => 'managed_file', + '#title' => $this->t('CSV File'), + '#description' => $this->t( + 'Upload a CSV file containing your transactions. Maximum file size allowed: %size', + ['%size' => $this->getReadableFileSize($maxCsvSize)] + ), + '#multiple' => FALSE, + '#upload_location' => 'private://imports', + '#upload_validators' => [ + 'file_validate_extensions' => ['csv'], + 'file_validate_size' => [$maxCsvSize], + ], + '#required' => TRUE, + ]; + + $form['file_description'] = [ + '#type' => 'item', + '#markup' => $this->t( + 'Required columns in the CSV: %columns.', + ['%columns' => $this->getNaturalLanguageJoin($this->getRequiredKeys())] + ), + ]; + + // Add a submit button that handles the submission of the form. + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Import'), + ]; + + return $form; + } + + protected function buildAccountForm(FormStateInterface $form_state) { + $accountFormState = new FormState(); + $accountForm = $this->accountForm->buildForm([], $accountFormState, 'brokerage', ContentBaseForm::DISPLAY_MODE_EMBEDDED); + $form_state->set('account_form', $accountForm); + $form_state->set('account_form_state', $accountFormState); + + return $accountForm; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $brokerages = $this->getBrokerageOptions(); + $brokerageSelected = $form_state->getValue('brokerage'); + + if ($brokerageSelected === 'new') { + $this->validateAccountForm($form_state); + } + // Make sure the selected brokerage account belongs to the current user. + elseif ($brokerageSelected !== 'new' && !isset($brokerages[$brokerageSelected])) { + $form_state->setErrorByName('brokerage', $this->t('Please select a brokerage account.')); + } + } + + protected function validateAccountForm(FormStateInterface $form_state) { + /** @var FormStateInterface $accountFormState */ + $newBrokerageTitle = trim($form_state->getValue('title')); + $accountForm = $form_state->get('account_form'); + $accountFormState = $form_state->get('account_form_state'); + $accountFormState->setValue('title', $newBrokerageTitle); + $accountFormState->setValue('type', 'brokerage'); + + $this->accountForm->validateForm($accountForm, $accountFormState); + $accountFormErrors = (array) $accountFormState->getErrors(); + + foreach ($accountFormErrors as $key => $error) { + $form_state->setErrorByName($key, $error); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + /** @var NodeInterface $brokerage */ + + $brokerageSelected = $form_state->getValue('brokerage'); + + if ($brokerageSelected === 'new') { + $brokerage = $this->submitAccountForm($form_state); + } + else { + $brokerage = $this->getBrokerageNodeFromUuid($brokerageSelected); + } + + if (NULL === $brokerage) { + $this->messenger()->addMessage( + $this->t('Unable to retrieve brokerage account.'), + MessengerInterface::TYPE_ERROR + ); + return FALSE; + } + + $csvFile = $form_state->getValue('csv_file'); + if (isset($csvFile[0])) { + try { + $file = $this->preserveFile($csvFile[0]); + $data = $this->parseCsvToArray($file->getFileUri()); + + $this->messenger()->addMessage( + $this->t('Hooray! Passed Validation!'), + MessengerInterface::TYPE_STATUS + ); + + return [ + 'brokerage' => $brokerage, + 'data' => $data, + ]; + } + catch (EntityStorageException $e) { + $this->messenger()->addMessage( + $this->t('Unable to process CSV file. Please try again.'), + MessengerInterface::TYPE_ERROR + ); + } + catch (CsvDataException $e) { + $this->messenger()->addMessage( + ['#type' => 'item', '#markup' => $e->getMessage()], + MessengerInterface::TYPE_ERROR + ); + } + } + + return FALSE; + } + + protected function submitAccountForm(FormStateInterface $form_state) { + $accountForm = $form_state->get('account_form'); + $accountFormState = $form_state->get('account_form_state'); + + $this->accountForm->submitForm($accountForm, $accountFormState); + return $accountFormState->get('account_node'); + } + + /** + * Remove whitespaces form a string and converts it to lowercase. + * @param string $key a string to sanitize + * @return string a sanitized string + */ + public static function sanitizeKey($key) { + $key = trim($key); + return strtolower($key); + } + + /** + * @return array an array of required keys + */ + protected abstract function getRequiredKeys(); + + protected function getMaxCsvSize() { + static $max_size = -1; + + if ($max_size < 0) { + // Start with site-wide max size. + $max_size = Environment::getUploadMaxSize(); + + // Reduce max_size if MAX_CSV_FILE_SIZE is less. Except if + // MAX_CSV_FILE_SIZE is zero, which indicates no limit. + $upload_max = Bytes::toInt(self::MAX_CSV_FILE_SIZE); + if ($upload_max > 0 && $upload_max < $max_size) { + $max_size = $upload_max; + } + } + + return $max_size; + } + + protected function getReadableFileSize($size, $precision = 2) { + $units = array('B','kB','MB','GB','TB','PB','EB','ZB','YB'); + $step = 1024; + $i = 0; + + while (($size / $step) > 0.9) { + $size = $size / $step; + $i++; + } + + return round($size, $precision).$units[$i]; + } + + protected function getNaturalLanguageJoin(array $list, $conjunction = 'and') { + $last = array_pop($list); + if ($list) { + return implode(', ', $list) . ' ' . $conjunction . ' ' . $last; + } + + return $last; + } + + protected function getBrokerageOptions() { + $brokerages = []; + $results = $this->getBrokerageNids(); + + if (!empty($results)) { + $brokerages['new'] = '- ' . $this->t('Create a brokerage account') . ' -'; + $nodes = $this->nodeStorage->loadMultiple($results); + foreach ($nodes as $nid => $node) { + + $label = (boolean) $node->field_account_status->value + ? $node->label() + : $node->label() . ' (inactive)'; + + $brokerages[$node->uuid()] = $label; + } + } + + return $brokerages; + } + + protected function getBrokerageNids() { + return $this->nodeStorage->getQuery() + ->condition('status', 1) + ->condition('type', 'account') + ->condition('uid', $this->currentUser()->id()) + ->condition('field_account_type', 'br') + ->execute(); + } + + /** + * @param string $uuid + * @return NodeInterface|null + */ + protected function getBrokerageNodeFromUuid($uuid) { + /** @var NodeInterface $brokerage */ + $brokerage = NULL; + + $results = $this->nodeStorage->getQuery() + ->condition('status', 1) + ->condition('type', 'account') + ->condition('uuid', $uuid) + ->condition('field_account_type', 'br') + ->range(0, 1) + ->execute(); + + if (!empty($results)) { + reset($results); + $brokerage = $this->nodeStorage->load(key($results)); + } + + return $brokerage; + } + + /** + * @param $fid + * @return FileInterface|null + * @throws EntityStorageException + */ + protected function preserveFile($fid) { + /** @var FileInterface $file */ + $file = $this->fileStorage->load($fid); + $file->setPermanent(); + $file->save(); + return $file; + } + + /** + * @param $file_uri + * @return array an associative array of CSV rows indexed by header keys + * @throws EntityStorageException + * @throws CsvDataException + */ + protected function parseCsvToArray($file_uri) { + + if(!file_exists($file_uri) || !is_readable($file_uri)) { + throw new EntityStorageException('File does not exists or is not readable.'); + } + + $header = NULL; + $rows = []; + + if (($handle = fopen($file_uri, 'r')) !== FALSE ) { + while (($row = fgetcsv($handle, 1000)) !== FALSE) { + if (!$header){ + // Sanitize keys in the header. + $header = array_map([__CLASS__, 'sanitizeKey'], $row); + + // Check for missing keys. + $missingRequiredKeys = array_diff($this->getRequiredKeys(), $header); + if (!empty($missingRequiredKeys)) { + throw new CsvDataException($this->t( + 'Missing required columns: %columns', + ['%columns' => implode($missingRequiredKeys, ', ')] + )); + } + + continue; + } + + $associativeRow = []; + foreach ($row as $i => $value) { + $associativeRow[$header[$i]] = $value; + } + $rows[] = $associativeRow; + } + + fclose($handle); + } + + return $rows; + } + + /** + * Parses a date string and converts it to ISO8601 format. + * @param string $date_string A date string. + * @return string A date string in ISO8601 format. + * @throws CsvDataException + * @todo Maybe this function can be swapped out with a Drupal/PHP function. + */ + protected function getIso8601DateString($date_string) { + $parsedDate = date_parse($date_string); + + if (FALSE === $parsedDate) { + throw new CsvDataException('Unable to parse date.'); + } + + if (!isset($parsedDate['year'])) { + throw new CsvDataException('Date is missing the year.'); + } + + $date = $parsedDate['year']; + + if (isset($parsedDate['month'])) { + $date .= '-' . str_pad($parsedDate['month'], 2, '0', STR_PAD_LEFT); + } + + if (isset($parsedDate['day'])) { + $date .= '-' . str_pad($parsedDate['day'], 2, '0', STR_PAD_LEFT); + } + + return $date; + } + + /** + * Find the taxonomy term ID of a given term. + * + * @param string $name The term name. + * @param string $vid The vocabulary ID. + * @param bool $create_term A boolean flag indicating whether to create + * a new term when not found. + * + * @return int|null Returns the term ID for a given term; null otherwise. + * + * @throws EntityStorageException + * @throws CsvDataException + * @throws StockApiException + */ + protected function getTermId($name, $vid, $create_term = FALSE) { + + // Extract symbol from + if ($vid === static::TERM_TYPE_SYMBOLS) { + $symbol = strtoupper($name); + + if (strpos($symbol, ':') !== FALSE) { + $symbolSplit = explode(':', $symbol); + + if (!$symbolSplit[1]) { + throw new CsvDataException('Invalid symbol provided.'); + } + + $name = $symbolSplit[1]; + } + } + + $termIds = $this->termStorage->getQuery() + ->condition('vid', $vid) + ->condition('name', $name) + ->range(0, 1) + ->execute(); + + if (!empty($termIds)) { + return reset($termIds); + } + + if ($create_term) { + return $this->createTerm($name, $vid); + } + + return NULL; + } + + /** + * Creates the taxonomy term entity for a given term. + * + * @param string $name The term name. + * @param string $vid The vocabulary ID. + * + * @return int|null Returns the term ID for a given term; null otherwise. + * + * @throws EntityStorageException + * @throws CsvDataException + * @throws StockApiException + */ + protected function createTerm($name, $vid) { + $data = [ + 'name' => $name, + 'vid' => $vid, + ]; + + if ($vid === static::TERM_TYPE_SYMBOLS) { + $profile = $this->stockApi->doSymbolLookup($name); + + if (empty($profile)) { + return NULL; + } + + $exchangeTid = $this->getTermId($profile['Exchange'], static::TERM_TYPE_EXCHANGES, TRUE); + $sectorTid = $this->getTermId($profile['Sector'], static::TERM_TYPE_SECTORS, TRUE); + $industryTid = $this->getTermId($profile['Industry'], static::TERM_TYPE_INDUSTRIES, TRUE); + + $data += [ + 'field_company_name' => $profile['Name'], + 'field_exchange' => ['target_id' => $exchangeTid], + 'field_sectors' => ['target_id' => $sectorTid], + 'field_industries' => ['target_id' => $industryTid], + ]; + } + + $term = $this->termStorage->create($data); + $term->save(); + return $term->id(); + } + +} + diff --git a/StockPortfolioManagement/daftar_import/src/Form/ImportCsvOrdersForm.php b/StockPortfolioManagement/daftar_import/src/Form/ImportCsvOrdersForm.php new file mode 100644 index 0000000..a74c02a --- /dev/null +++ b/StockPortfolioManagement/daftar_import/src/Form/ImportCsvOrdersForm.php @@ -0,0 +1,130 @@ +t('Import transactions such as Buy, Sell, Sell Short and Buy to Cover from your brokerage.'); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $data = parent::submitForm($form, $form_state); + + // End processing if no CSV data was retrieved. + if (FALSE === $data) { + return; + } + + $currentRow = 1; // Starts at 1 because first row is a header row. + try { + $this->orderStorage = \Drupal::entityTypeManager()->getStorage('transaction_order'); + $brokerageNodeId = $data['brokerage']->id(); + + foreach ($data['data'] as $row) { + $currentRow++; + $this->createOrder($brokerageNodeId, $row); + } + } + catch(CsvDataException $e) { + $this->messenger->addError( + 'Data error on row ' . $currentRow . ': ' . $e->getMessage() + ); + } + catch (\Exception $e) { + // @TODO log to watchdog + $this->messenger()->addError( + "Import process stopped at row {$currentRow} due to internal errors." + ); + } + } + + /** + * Creates the order entity. + * + * @param int $aidBrokerage Node ID of the brokerage account. + * @param array $data An associative array containing CSV row. + * + * @throws CsvDataException + * @throws EntityStorageException + * @throws StockApiException + */ + protected function createOrder($aidBrokerage, array $data) { + + $dateIso8601 = $this->getIso8601DateString($data['date']); + $symbolTid = $this->getTermId($data['symbol'], static::TERM_TYPE_SYMBOLS, TRUE); + + $action = Order::mapAction($data['action']); + if (!$action) { + throw new CsvDataException('Unable to parse order action.'); + } + + $type = Order::mapType($data['type']); + if (!$type) { + throw new CsvDataException('Unable to parse order type.'); + } + + $this->orderStorage->create([ + 'aid' => $aidBrokerage, + 'transaction_date' => $dateIso8601, + 'symbol' => $symbolTid, + 'action' => $action, + 'type' => $type, + 'amount' => $data['amount'], + 'quantity' => $data['quantity'], + 'on_hand' => $data['quantity'], + 'commission' => (isset($data['commission']) && $data['commission'] !== NULL) ? $data['commission'] : 0, + 'fees' => (isset($data['fees']) && $data['fees'] !== NULL) ? $data['fees'] : 0, + 'notes' => (isset($data['notes'])) ? $data['notes'] : NULL, + ])->save(); + } + +} + diff --git a/StockPortfolioManagement/daftar_import/src/Form/ImportCsvTransfersForm.php b/StockPortfolioManagement/daftar_import/src/Form/ImportCsvTransfersForm.php new file mode 100644 index 0000000..721a4ed --- /dev/null +++ b/StockPortfolioManagement/daftar_import/src/Form/ImportCsvTransfersForm.php @@ -0,0 +1,188 @@ +t('Import transactions such as deposits and withdrawals.'); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $data = parent::submitForm($form, $form_state); + + // End processing if no CSV data was retrieved. + if (FALSE === $data) { + return; + } + + $currentRow = 1; // Starts at 1 because first row is a header row. + try { + $this->transferStorage = \Drupal::entityTypeManager()->getStorage('transaction_transfer'); + $brokerageNodeId = $data['brokerage']->id(); + + foreach ($data['data'] as $row) { + $currentRow++; + $this->createTransfer($brokerageNodeId, $row); + } + } + catch (CsvDataException $e) { + $this->messenger->addError( + 'Data error on row ' . $currentRow . ': ' . $e->getMessage() + ); + } + catch (\Exception $e) { + // @TODO log to watchdog + $this->messenger()->addError( + "Import process stopped at row {$currentRow} due to internal errors." + ); + } + + } + + /** + * Find the bank account node ID of a given account alias. + * + * @param string $alias Alias for the bank account. + * @param bool $create_account A boolean flag indicating whether to create + * a new bank account when not found. + * + * @return int|null Returns the bank account node ID for a given alias; null otherwise. + * @throws \Exception + */ + protected function getBankNodeId($alias, $create_account = FALSE) { + $nodeIds = $this->nodeStorage->getQuery() + ->condition('status', 1) + ->condition('type', 'account') + ->condition('uid', $this->currentUser()->id()) + ->condition('title', $alias) + ->condition('field_account_type', 'ba') + ->range(0, 1) + ->execute(); + + if (!empty($nodeIds)) { + return reset($nodeIds); + } + + if ($create_account) { + return $this->createBankAccount($alias); + } + + return NULL; + } + + /** + * Creates a bank account for the user. + * + * @param string $alias Alias for the bank account. + * + * @return int|null Returns the bank account node ID when successfully created; null otherwise. + * + * @throws CsvDataException + */ + protected function createBankAccount($alias) { + $accountFormState = new FormState(); + $accountForm = $this->accountForm->buildForm([], $accountFormState, 'bank'); + + $accountFormState->setValue('title', $alias); + $accountFormState->setValue('type', 'bank'); + + $this->accountForm->validateForm($accountForm, $accountFormState); + $accountFormErrors = (array) $accountFormState->getErrors(); + + if (!empty($accountFormErrors)) { + throw new CsvDataException("Unable to create bank account: " . reset($accountFormErrors)); + } + + $this->accountForm->submitForm($accountForm, $accountFormState); + + $node = $accountFormState->get('account_node'); + if ($node) { + return $node->id(); + } + + return NULL; + } + + /** + * Creates the transfer entity. + * + * @param int $aidBrokerage Node ID of the brokerage account. + * @param array $data An associative array containing CSV row. + * + * @throws CsvDataException + * @throws EntityStorageException + * @throws \Exception + */ + protected function createTransfer($aidBrokerage, $data) { + $dateIso8601 = $this->getIso8601DateString($data['date']); + + $bankNodeId = $this->getBankNodeId($data['account'], TRUE); + if (!$bankNodeId) { + throw new \Exception("Unable to create bank account for alias {$data['account']}."); + } + + if (strtolower($data['action']) === 'deposit') { + $aid = $aidBrokerage; + $accountWithdrawn = $bankNodeId; + } + else { + $aid = $bankNodeId; + $accountWithdrawn = $aidBrokerage; + } + + $this->transferStorage->create([ + 'aid' => $aid, + 'account_withdrawn' => $accountWithdrawn, + 'amount' => $data['amount'], + 'transaction_date' => $dateIso8601, + ])->save(); + } + +} + diff --git a/StockPortfolioManagement/daftar_quote/daftar_quote.info.yml b/StockPortfolioManagement/daftar_quote/daftar_quote.info.yml new file mode 100644 index 0000000..e0b6b50 --- /dev/null +++ b/StockPortfolioManagement/daftar_quote/daftar_quote.info.yml @@ -0,0 +1,9 @@ +name: Dafter Quote +description: Provides quotes for a security, ETF or an option. +core: 8.x +type: module +package: Daftar +core_version_requirement: ^8 || ^9 +version: 1.0.0 +dependencies: + - daftar_stock_api:daftar_stock_api diff --git a/StockPortfolioManagement/daftar_quote/daftar_quote.routing.yml b/StockPortfolioManagement/daftar_quote/daftar_quote.routing.yml new file mode 100644 index 0000000..a180173 --- /dev/null +++ b/StockPortfolioManagement/daftar_quote/daftar_quote.routing.yml @@ -0,0 +1,35 @@ +daftar_quote.options: + path: '/quote/options' + defaults: + _title: 'Options Quote' + _controller: '\Drupal\daftar_quote\Controller\OptionsController::quote' + requirements: + _access: 'TRUE' + +daftar_quote.v2.options.contracts: + path: '/v2/option/contracts/{symbol}/{type}/{expiration_date}' + defaults: + _title: 'Options Quote' + _controller: '\Drupal\daftar_quote\Controller\OptionsController::v2_contracts' + requirements: + _access: 'TRUE' + symbol: '[A-Z]{1,5}' + type: 'calls|puts' + expiration_date: '\d{4}-\d{2}-\d{2}' + +daftar_quote.v2.options.contract: + path: '/v2/option/contracts/{contract_symbol}' + defaults: + _title: 'Option Contract' + _controller: '\Drupal\daftar_quote\Controller\OptionsController::v2_contract' + requirements: + _access: 'TRUE' + #contract_symbol: '[A-Z]{1,5}\d{6}(P|C)\d{8}' + +daftar_quote.v2.options.parse_contract_symbol: + path: '/v2/option/contracts/parse' + defaults: + _title: 'Option Contract Parse' + _controller: '\Drupal\daftar_quote\Controller\OptionsController::v2_parse_contract_symbol' + requirements: + _access: 'TRUE' diff --git a/StockPortfolioManagement/daftar_quote/src/Controller/OptionsController.php b/StockPortfolioManagement/daftar_quote/src/Controller/OptionsController.php new file mode 100644 index 0000000..017886a --- /dev/null +++ b/StockPortfolioManagement/daftar_quote/src/Controller/OptionsController.php @@ -0,0 +1,292 @@ +yahooParser = $yahoo_parser_service; + $this->yahooApi = $yahoo_api_service; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): OptionsController { + return new static( + $container->get('daftar.stock_api.yahoo_parser'), + $container->get('daftar.stock_api.yahoo_api') + ); + } + + /** + * @param string $symbol + * @param string $type + * @param string $expiration_date + * @param Request $request + * + * @return JsonResponse + */ + public function v2_contracts(string $symbol, string $type, string $expiration_date, Request $request): JsonResponse { + $tokenCheck = $this->checkToken($request); + if ($tokenCheck) { + return $tokenCheck; + } + + $strike = $request->get('strike'); + return $this->v2_fetchContracts($symbol, $type, $expiration_date, $strike); + } + + /** + * @param string $contract_symbol + * @param Request $request + * + * @return JsonResponse + */ + public function v2_contract(string $contract_symbol, Request $request): JsonResponse { + $tokenCheck = $this->checkToken($request); + if ($tokenCheck) { + return $tokenCheck; + } + + if (preg_match(self::CONTRACT_SYMBOL_PATTERN, $contract_symbol, $matches)) { + $date = DateTime::createFromFormat('ymd', $matches[2]); + $type = ($matches[3] === 'P') ? 'puts' : 'calls'; + $strike = intval($matches[4])/1000; + return $this->v2_fetchContracts($matches[1], $type, $date->format('Y-m-d'), $strike); + } + + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Invalid contract symbol.'), + ], Response::HTTP_BAD_REQUEST); + } + + /** + * @param string $contract_symbol + * @param Request $request + * + * @return JsonResponse + */ + public function v2_parse_contract_symbol(Request $request): JsonResponse { + $tokenCheck = $this->checkToken($request); + if ($tokenCheck) { + return $tokenCheck; + } + + $parsed = []; + $contractSymbol = $request->request->get('contract_symbol'); + //$contractSymbol = $request->get('contract_symbol'); + if (preg_match(self::CONTRACT_SYMBOL_PATTERN, $contractSymbol, $matches)) { + $date = DateTime::createFromFormat('ymd', $matches[2]); + $parsed = [ + 'symbol' => $matches[1], + 'expiration_date' => $date->format('m/d/Y'), + 'type' => ($matches[3] === 'P') ? 'PUT' : 'CALL', + 'strike' => intval($matches[4])/1000, + 'account' => '', + ]; + } + elseif (preg_match(self::CONTRACT_SYMBOL_FD_PATTERN, $contractSymbol, $matches)) { + $date = DateTime::createFromFormat('ymd', $matches[2]); + $parsed = [ + 'symbol' => $matches[1], + 'expiration_date' => $date->format('m/d/Y'), + 'type' => ($matches[3] === 'P') ? 'PUT' : 'CALL', + 'strike' => $matches[4], + 'account' => 'FD', + ]; + } + elseif (preg_match(self::CONTRACT_SYMBOL_FD_PATTERN2, $contractSymbol, $matches) + || preg_match(self::CONTRACT_SYMBOL_FD_PATTERN2B, $contractSymbol, $matches)) { + $date = DateTime::createFromFormat('ymd', $matches[5]); + $action = substr($matches[2], 0, 1) . '2'; + $action .= ($matches[3] === 'CLOSING') ? 'X' : 'O'; + + $parsed = [ + 'tran_date' => $matches[1], + 'action' => $action, + 'symbol' => $matches[4], + 'expiration_date' => $date->format('m/d/Y'), + 'type' => ($matches[6] === 'P') ? 'PUT' : 'CALL', + 'strike' => $matches[7], + 'quantity' => $matches[8], + 'account' => 'FD2', + 'price' => $matches[9], + 'comm' => $matches[11], + 'fees' => $matches[12] + ]; + } + elseif (preg_match(self::CONTRACT_SYMBOL_TDA_PATTERN, $contractSymbol, $matches)) { + $date = DateTime::createFromFormat('M d Y', $matches[4]); + $parsed = [ + 'symbol' => $matches[3], + 'expiration_date' => $date->format('m/d/Y'), + 'type' => ($matches[7] === 'Put') ? 'PUT' : 'CALL', + 'strike' => $matches[5], + 'account' => 'TDA', + 'action' => ($matches[1] === 'Sold') ? 'S2O' : 'B2O', + 'quantity' => $matches[2], + 'price' => $matches[8], + ]; + } + + if ($parsed) { + $parsed['contract'] = NULL; + try { + $type = ($parsed['type'] == 'CALL') ? 'calls' : 'puts'; + $contractData = $this->yahooApi->getOptionContract( + $parsed['symbol'], $parsed['expiration_date'], $type, $parsed['strike'] + ); + if (!$contractData['error']) { + $parsed['contract'] = $contractData['contents']; + } + + } catch (StockApiException $e) { + // do nothing + } + + return new JsonResponse([ + 'error' => FALSE, + 'message' => '', + 'contents' => $parsed, + ], Response::HTTP_OK); + } + else { + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Invalid or missing contract symbol.'), + 'contract_symbol' => $contractSymbol, + ], Response::HTTP_BAD_REQUEST); + } + + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function quote(Request $request): JsonResponse { + $tokenCheck = $this->checkToken($request); + if ($tokenCheck) { + return $tokenCheck; + } + + $symbol = $request->get('symbol'); + if (!$symbol) { + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Missing symbol.'), + ], Response::HTTP_BAD_REQUEST); + } + + $price = $this->yahooParser->getPrice($symbol); + if ($price) { + return new JsonResponse([ + 'error' => FALSE, + 'message' => 'success', + 'price' => $price, + ], Response::HTTP_OK); + } + + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Unable to parse price.'), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + /** + * @param string $symbol + * @param string $type + * @param string $expiration_date + * @param $strike + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + */ + private function v2_fetchContracts(string $symbol, string $type, string $expiration_date, $strike = ""): JsonResponse { + if ($type == 'CALL') { + $type == 'calls'; + } + if ($type == 'PUT') { + $type == 'puts'; + } + + try { + if ($strike) { + $data = $this->yahooApi->getOptionContract($symbol, $expiration_date, $type, $strike); + } + else { + $data = $this->yahooApi->getOptionContracts($symbol, $expiration_date, $type); + } + return new JsonResponse($data, Response::HTTP_OK); + } + catch (StockApiException $e) { + //do nothing + } + + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Error occurred while finding contract.'), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + private function checkToken(Request $request): ?JsonResponse { + $optionsToken = Settings::get(static::SETTINGS_KEY_OPTIONS_TOKEN, ''); + if (!$optionsToken) { + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Token setup has not been completed.'), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $receivedToken = $request->get('token'); + if ($receivedToken !== $optionsToken) { + return new JsonResponse([ + 'error' => TRUE, + 'message' => $this->t('Invalid token.'), + ], Response::HTTP_FORBIDDEN); + } + + return NULL; + } + +} +