From bfc5e5e24280c9d700e27e37aefbec382a1fbeda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 17 Sep 2023 19:36:18 +0200 Subject: [PATCH] WIP: Implement profile editing --- assets/styles/_components.css | 4 +- src/BusProNet/ApiClient.php | 87 +++++++++++++++++-- src/BusProNet/Model/CountriesResponse.php | 20 +++++ src/BusProNet/Model/Country.php | 59 +++++++++++++ src/BusProNet/ResponseParser.php | 27 +++++- src/Controller/IndexController.php | 24 +++++ src/Controller/Teamer/ProfileController.php | 57 ++++++++++++ src/Entity/Embeddable/Address.php | 11 +-- src/Entity/Embeddable/Communication.php | 9 ++ src/Entity/Teamer.php | 22 +++++ src/Form/BpnCountryType.php | 38 ++++++++ .../ChoiceLoader/BpnCountryChoiceLoader.php | 47 ++++++++++ src/Form/ProfileType.php | 53 +++++++++++ src/Security/BpnAuthenticator.php | 5 +- tailwind.config.js | 4 + templates/forms.html.twig | 24 ++++- templates/teamer/profile.html.twig | 25 ++++++ 17 files changed, 498 insertions(+), 18 deletions(-) create mode 100644 src/BusProNet/Model/CountriesResponse.php create mode 100644 src/BusProNet/Model/Country.php create mode 100644 src/Controller/IndexController.php create mode 100644 src/Controller/Teamer/ProfileController.php create mode 100644 src/Form/BpnCountryType.php create mode 100644 src/Form/ChoiceLoader/BpnCountryChoiceLoader.php create mode 100644 src/Form/ProfileType.php create mode 100644 templates/teamer/profile.html.twig diff --git a/assets/styles/_components.css b/assets/styles/_components.css index 4583450..689e4ce 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,7 +1,7 @@ .btn { - @apply px-8 py-1 border bg-primary border-primary text-white; + @apply inline-flex justify-center rounded-lg text-sm font-semibold py-2.5 px-4 bg-primary text-white hover:bg-primary/80 w-full; } .btn--secondary { - @apply bg-secondary border-secondary text-gray-800; + @apply bg-secondary text-gray-800 hover:bg-secondary/80; } diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index f625fae..35e1e57 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -3,8 +3,11 @@ namespace App\BusProNet; use App\BusProNet\Model\BaseResponse; +use App\Entity\User; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class ApiClient @@ -15,6 +18,7 @@ class ApiClient private readonly HttpClientInterface $httpClient, private readonly SerializerInterface $serializer, private readonly ResponseParser $responseParser, + private readonly CacheInterface $cache, array $options ) { $this->config = $this->resolveOptions($options); @@ -38,8 +42,7 @@ class ApiClient $body = $this ->serializer - ->serialize($data, 'xml') - ; + ->serialize($data, 'xml'); try { $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ @@ -57,8 +60,44 @@ class ApiClient throw new ApiClientException($e->getMessage()); } - public function updateProfile(): void - {} + public function updateProfile(User $user, string $password): BaseResponse + { + if (null === $teamer = $user->getTeamer()) { + throw new ApiClientException('Invalid argument'); + } + + $data = [ + 'anfrage' => [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], 'KUNDENKONTO'), + 'satz' => ['@typ' => 'KUNDENKONTO'], + 'art' => 'Adressdaten_Ändern', + 'email' => $user->getEmail(), + 'passwort' => md5($password), + 'idadresse' => $user->getBusProAddressId(), + 'adressdaten' => $teamer->toPayload(), + ], + ]; + + $body = $this + ->serializer + ->serialize($data, 'xml'); + + try { + $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ + 'query' => [ + 'operation' => $body, + ] + ]); + + $xml = $response->getContent(); + + return $this->responseParser->parseXmlString($xml); + } catch (\Throwable $e) { + } + + throw new ApiClientException($e->getMessage()); + } public function resetPassword(string $email): BaseResponse { @@ -74,8 +113,7 @@ class ApiClient $body = $this ->serializer - ->serialize($data, 'xml') - ; + ->serialize($data, 'xml'); try { $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ @@ -111,8 +149,7 @@ class ApiClient $body = $this ->serializer - ->serialize($data, 'xml') - ; + ->serialize($data, 'xml'); try { $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ @@ -130,6 +167,40 @@ class ApiClient throw new ApiClientException($e->getMessage()); } + public function getCountries(): BaseResponse + { + return $this->cache->get('bpn_countries', function (ItemInterface $item) { + $item->expiresAfter(3600); + + $data = [ + 'anfrage' => [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], 'STAMMLAENDER'), + 'satz' => ['@typ' => 'STAMMLAENDER'], + ], + ]; + + $body = $this + ->serializer + ->serialize($data, 'xml'); + + try { + $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ + 'query' => [ + 'operation' => $body, + ] + ]); + + $xml = $response->getContent(); + + return $this->responseParser->parseXmlString($xml); + } catch (\Throwable $e) { + } + + throw new ApiClientException($e->getMessage()); + }); + } + private function createKey(string $username, string $password, string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); diff --git a/src/BusProNet/Model/CountriesResponse.php b/src/BusProNet/Model/CountriesResponse.php new file mode 100644 index 0000000..1e1f94c --- /dev/null +++ b/src/BusProNet/Model/CountriesResponse.php @@ -0,0 +1,20 @@ +countries; + } + + public function setCountries(array $countries): static + { + $this->countries = $countries; + + return $this; + } +} \ No newline at end of file diff --git a/src/BusProNet/Model/Country.php b/src/BusProNet/Model/Country.php new file mode 100644 index 0000000..500451c --- /dev/null +++ b/src/BusProNet/Model/Country.php @@ -0,0 +1,59 @@ +id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(?string $token): static + { + $this->token = $token; + + return $this; + } + + public function getNationality(): ?string + { + return $this->nationality; + } + + public function setNationality(?string $nationality): static + { + $this->nationality = $nationality; + + return $this; + } +} \ No newline at end of file diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php index 631b66b..c38a3ad 100644 --- a/src/BusProNet/ResponseParser.php +++ b/src/BusProNet/ResponseParser.php @@ -4,6 +4,8 @@ namespace App\BusProNet; use App\BusProNet\Model\Address; use App\BusProNet\Model\Communication; +use App\BusProNet\Model\CountriesResponse; +use App\BusProNet\Model\Country; use App\BusProNet\Model\CrmAttribute; use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributeGroup; @@ -39,6 +41,8 @@ class ResponseParser case 'SelektionCRM': return $this->createCrmAttributesResponse($xml); } + case 'STAMMLAENDER': + return $this->createCountriesResponse($xml); } throw new ResponseParserException('Unable to parse XML response'); @@ -63,7 +67,7 @@ class ResponseParser $title = (string) $xml->xpath('adressdaten/titel')[0]; $gender = (string) $xml->xpath('adressdaten/geschlecht')[0]; - $gender = strtolower($gender) === 'w' ? 'f' : strtolower($gender); + $gender = strtoupper($gender) === 'W' ? 'F' : strtoupper($gender); $date = $xml->xpath('adressdaten/geburtsdatum'); $dateOfBirth = $date ?\DateTimeImmutable::createFromFormat('d.m.Y', (string) $date[0]) : null; @@ -155,6 +159,27 @@ class ResponseParser return $response; } + public function createCountriesResponse(\SimpleXMLElement $xml): CountriesResponse + { + $countries = []; + + foreach ($xml->xpath('laender/land') as $item) { + $country = new Country(); + $country + ->setId((int)$item->attributes()['id']) + ->setName((string)$item->attributes()['bezeichnung']) + ->setToken((string)$item->attributes()['kuerzel']) + ->setNationality((string)$item->attributes()['nationalitaet']) + ; + $countries[] = $country; + } + + $response = new CountriesResponse(); + $response->setCountries($countries); + + return $response; + } + private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php new file mode 100644 index 0000000..2534dda --- /dev/null +++ b/src/Controller/IndexController.php @@ -0,0 +1,24 @@ +getUser(); + + if (null === $user) { + return $this->redirectToRoute('app_security_login'); + } + + return $this->redirectToRoute($user->getDefaultRoute()); + } +} \ No newline at end of file diff --git a/src/Controller/Teamer/ProfileController.php b/src/Controller/Teamer/ProfileController.php new file mode 100644 index 0000000..c8a9a3b --- /dev/null +++ b/src/Controller/Teamer/ProfileController.php @@ -0,0 +1,57 @@ +getUser(); + $teamer = $user->getTeamer(); + + if (null === $teamer) { + $teamer = new Teamer(); + $user->setTeamer($teamer); + $this->entityManager->persist($teamer); + } + + $form = $this->createForm(ProfileType::class, $teamer); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $bpnPassword = $request->getSession()->get('bpn_password'); + $this->apiClient->updateProfile($user, $bpnPassword); + $this->entityManager->flush(); + } catch (ApiClientException $e) { + } + + return $this->redirectToRoute('app_teamer_profile'); + } + + return $this->render('teamer/profile.html.twig', [ + 'form' => $form->createView(), + ]); + } +} \ No newline at end of file diff --git a/src/Entity/Embeddable/Address.php b/src/Entity/Embeddable/Address.php index 233ccd0..572dfc2 100644 --- a/src/Entity/Embeddable/Address.php +++ b/src/Entity/Embeddable/Address.php @@ -25,13 +25,14 @@ class Address #[ORM\Column(type: 'string', nullable: true)] protected ?string $country = null; - public function toArray(): array + public function toPayload(): array { return [ - 'street' => $this->getStreet(), - 'post_code' => $this->getPostCode(), - 'city' => $this->getCity(), - 'country' => $this->getCountry(), + 'strasse' => $this->getStreet(), + 'plz' => $this->getPostCode(), + 'ort' => $this->getCity(), + 'ortsteil' => '', + 'land' => $this->getCountry(), ]; } diff --git a/src/Entity/Embeddable/Communication.php b/src/Entity/Embeddable/Communication.php index c925757..c940b86 100644 --- a/src/Entity/Embeddable/Communication.php +++ b/src/Entity/Embeddable/Communication.php @@ -19,6 +19,15 @@ class Communication #[Assert\Email(mode: 'strict')] protected ?string $email = null; + public function toPayload(): array + { + return [ + 'email' => $this->getEmail(), + 'telefonmobil' => $this->getMobile(), + 'telefonprivat' => $this->getPhone(), + ]; + } + public static function fromApiResponse(ProfileResponse $profileResponse): static { $communication = $profileResponse->getCommunication(); diff --git a/src/Entity/Teamer.php b/src/Entity/Teamer.php index be78ff9..ab1f776 100644 --- a/src/Entity/Teamer.php +++ b/src/Entity/Teamer.php @@ -117,6 +117,28 @@ class Teamer implements TimestampableEntityInterface $this->dispositions = new ArrayCollection(); } + public function toPayload(): array + { + // Transform gender value + $gender = strtoupper($this->getGender()); + $gender = 'F' ? 'W' : $gender; + + // Ensure date of birth is populated + if (null === $dob = $this->getDateOfBirth()) { + $dob = new \DateTimeImmutable('18 years ago'); + } + + return [ + 'geburtsdatum' => $dob->format('d.m.Y'), + 'geschlecht' => $gender, + 'titel' => $this->getAcademicTitle(), + 'vorname' => $this->getFirstName(), + 'name' => $this->getLastName(), + 'anschrift' => $this->getAddress()->toPayload(), + 'kommunikation' => $this->getCommunication()->toPayload(), + ]; + } + public static function fromApiResponse(ProfileResponse $profileResponse): static { $instance = new static(); diff --git a/src/Form/BpnCountryType.php b/src/Form/BpnCountryType.php new file mode 100644 index 0000000..56545ed --- /dev/null +++ b/src/Form/BpnCountryType.php @@ -0,0 +1,38 @@ +setDefined(['property']); + $resolver->setAllowedValues('property', ['country', 'nationality']); + $resolver->setDefaults([ + 'property' => 'country', + 'choice_loader' => function (Options $options) { + return ChoiceList::loader( + $this, + new BpnCountryChoiceLoader($this->apiClient, $options['property']), + [$options['property']] + ); + }, + ]); + } +} \ No newline at end of file diff --git a/src/Form/ChoiceLoader/BpnCountryChoiceLoader.php b/src/Form/ChoiceLoader/BpnCountryChoiceLoader.php new file mode 100644 index 0000000..6442dd0 --- /dev/null +++ b/src/Form/ChoiceLoader/BpnCountryChoiceLoader.php @@ -0,0 +1,47 @@ +apiClient->getCountries(); + $countries = $response->getCountries(); + } catch (ApiClientException $e) { + $countries = []; + } + + $choices = []; + + foreach ($countries as $country) { + $key = 'nationality' === $this->property ? $country->getNationality() : $country->getName(); + $choices[$key] = $country->getId(); + } + + return new ArrayChoiceList($choices); + + } + + public function loadChoicesForValues(array $values, callable $value = null): array + { + return $values; + } + + public function loadValuesForChoices(array $choices, callable $value = null): array + { + return $choices; + } +} \ No newline at end of file diff --git a/src/Form/ProfileType.php b/src/Form/ProfileType.php new file mode 100644 index 0000000..7c84d77 --- /dev/null +++ b/src/Form/ProfileType.php @@ -0,0 +1,53 @@ +add('gender', ChoiceType::class, [ + 'label' => 'Geschlecht', + 'choices' => [ + 'weiblich' => 'W', + 'männlich' => 'M', + 'divers' => 'D', + ], + ]) + ->add('firstName', TextType::class, [ + 'label' => 'Vorname', + ]) + ->add('lastName', TextType::class,[ + 'label' => 'Nachname', + ]) + ->add('academicTitle', TextType::class, [ + 'label' => 'Titel', + 'required' => false, + ]) + ->add('dateOfBirth', BirthdayType::class, [ + 'label' => 'Geburtsdatum', + 'input_format' => 'datetime_immutable', + ]) + ->add('nationality', BpnCountryType::class, [ + 'label' => 'Nationalität', + 'property' => 'nationality', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Teamer::class, + ]); + } +} \ No newline at end of file diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index e49cc84..c1c99fc 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -48,7 +48,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $csrfToken = $request->request->get('_csrf_token', ''); return new SelfValidatingPassport( - new UserBadge($email, function () use ($email, $password) { + new UserBadge($email, function () use ($email, $password, $request) { try { $response = $this->apiClient->getProfile($email, $password); } catch (ApiClientException $e) { @@ -59,6 +59,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return null; } + // Store BPN password in session for later use + $request->getSession()->set('bpn_password', $password); + return $this->getOrCreateLocalUser($response, $email, $password); }), [new CsrfTokenBadge('authenticate', $csrfToken)] diff --git a/tailwind.config.js b/tailwind.config.js index 6cfe37c..925ae99 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,6 +4,10 @@ module.exports = { './templates/**/*.twig', ], theme: { + container: { + center: true, + padding: '2rem', + }, extend: { fontFamily: { sans: ['Lato', 'sans-serif'], diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 8fe28ee..dc8e013 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -3,7 +3,7 @@ {%- block form_widget_simple -%} {%- set type = type|default('text') -%} {%- if type != 'hidden' -%} - {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' mt-2 appearance-none text-slate-900 bg-white rounded-md block w-full px-3 h-10 shadow-sm sm:text-sm focus:outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-primary ring-1 ring-slate-200')|trim }) -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' mt-2 appearance-none text-slate-900 bg-white rounded-md block w-full px-3 h-10 shadow-sm sm:text-sm focus:outline-none ring-0 placeholder:text-slate-400 focus:ring-1 focus:ring-primary')|trim }) -%} {%- if errors|length -%} {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} {%- endif -%} @@ -16,3 +16,25 @@ {%- endif -%} {%- endblock form_widget_simple -%} + +{%- block choice_widget_collapsed -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' mt-2 appearance-none text-slate-900 bg-white rounded-md block w-full px-3 h-10 shadow-sm sm:text-sm focus:outline-none ring-0 placeholder:text-slate-400 focus:ring-1 focus:ring-primary' }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} + {{ parent() }} + {%- if disabled is defined and disabled == true -%} + + {%- endif -%} +{%- endblock choice_widget_collapsed -%} + +{%- block birthday_widget -%} +
+ {{ form_widget(form.children['day']) }} + {{ form_widget(form.children['month']) }} + {{ form_widget(form.children['year']) }} +
+{%- endblock -%} \ No newline at end of file diff --git a/templates/teamer/profile.html.twig b/templates/teamer/profile.html.twig new file mode 100644 index 0000000..a874dd8 --- /dev/null +++ b/templates/teamer/profile.html.twig @@ -0,0 +1,25 @@ +{% extends 'base.html.twig' %} + +{% block body %} + {{ form_start(form) }} +
+
+
+ {{ form_row(form.academicTitle) }} + {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} + {{ form_row(form.gender) }} + {{ form_row(form.dateOfBirth) }} + {{ form_row(form.nationality) }} +
+
+
+ +
+
+
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} \ No newline at end of file