WIP: Implement profile editing

This commit is contained in:
Björn Fromme
2023-09-17 19:36:18 +02:00
parent 97c5a686af
commit bfc5e5e242
17 changed files with 498 additions and 18 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
.btn { .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 { .btn--secondary {
@apply bg-secondary border-secondary text-gray-800; @apply bg-secondary text-gray-800 hover:bg-secondary/80;
} }
+79 -8
View File
@@ -3,8 +3,11 @@
namespace App\BusProNet; namespace App\BusProNet;
use App\BusProNet\Model\BaseResponse; use App\BusProNet\Model\BaseResponse;
use App\Entity\User;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiClient class ApiClient
@@ -15,6 +18,7 @@ class ApiClient
private readonly HttpClientInterface $httpClient, private readonly HttpClientInterface $httpClient,
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly ResponseParser $responseParser, private readonly ResponseParser $responseParser,
private readonly CacheInterface $cache,
array $options array $options
) { ) {
$this->config = $this->resolveOptions($options); $this->config = $this->resolveOptions($options);
@@ -38,8 +42,7 @@ class ApiClient
$body = $this $body = $this
->serializer ->serializer
->serialize($data, 'xml') ->serialize($data, 'xml');
;
try { try {
$response = $this->httpClient->request('GET', $this->config['bpn_url'], [ $response = $this->httpClient->request('GET', $this->config['bpn_url'], [
@@ -57,8 +60,44 @@ class ApiClient
throw new ApiClientException($e->getMessage()); 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 public function resetPassword(string $email): BaseResponse
{ {
@@ -74,8 +113,7 @@ class ApiClient
$body = $this $body = $this
->serializer ->serializer
->serialize($data, 'xml') ->serialize($data, 'xml');
;
try { try {
$response = $this->httpClient->request('GET', $this->config['bpn_url'], [ $response = $this->httpClient->request('GET', $this->config['bpn_url'], [
@@ -111,8 +149,7 @@ class ApiClient
$body = $this $body = $this
->serializer ->serializer
->serialize($data, 'xml') ->serialize($data, 'xml');
;
try { try {
$response = $this->httpClient->request('GET', $this->config['bpn_url'], [ $response = $this->httpClient->request('GET', $this->config['bpn_url'], [
@@ -130,6 +167,40 @@ class ApiClient
throw new ApiClientException($e->getMessage()); 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 private function createKey(string $username, string $password, string $type): string
{ {
$date = (new \DateTimeImmutable())->format('Ymd'); $date = (new \DateTimeImmutable())->format('Ymd');
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\BusProNet\Model;
class CountriesResponse extends BaseResponse
{
private array $countries = [];
public function getCountries(): array
{
return $this->countries;
}
public function setCountries(array $countries): static
{
$this->countries = $countries;
return $this;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\BusProNet\Model;
class Country
{
private ?int $id = null;
private ?string $name = null;
private ?string $token = null;
private ?string $nationality = null;
public function getId(): ?int
{
return $this->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;
}
}
+26 -1
View File
@@ -4,6 +4,8 @@ namespace App\BusProNet;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\Communication; use App\BusProNet\Model\Communication;
use App\BusProNet\Model\CountriesResponse;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\CrmAttribute; use App\BusProNet\Model\CrmAttribute;
use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\CrmAttributeGroup; use App\BusProNet\Model\CrmAttributeGroup;
@@ -39,6 +41,8 @@ class ResponseParser
case 'SelektionCRM': case 'SelektionCRM':
return $this->createCrmAttributesResponse($xml); return $this->createCrmAttributesResponse($xml);
} }
case 'STAMMLAENDER':
return $this->createCountriesResponse($xml);
} }
throw new ResponseParserException('Unable to parse XML response'); throw new ResponseParserException('Unable to parse XML response');
@@ -63,7 +67,7 @@ class ResponseParser
$title = (string) $xml->xpath('adressdaten/titel')[0]; $title = (string) $xml->xpath('adressdaten/titel')[0];
$gender = (string) $xml->xpath('adressdaten/geschlecht')[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'); $date = $xml->xpath('adressdaten/geburtsdatum');
$dateOfBirth = $date ?\DateTimeImmutable::createFromFormat('d.m.Y', (string) $date[0]) : null; $dateOfBirth = $date ?\DateTimeImmutable::createFromFormat('d.m.Y', (string) $date[0]) : null;
@@ -155,6 +159,27 @@ class ResponseParser
return $response; 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 private function resolveOptions(array $options): array
{ {
$optionsResolver = new OptionsResolver(); $optionsResolver = new OptionsResolver();
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class IndexController extends AbstractController
{
#[Route('/', name: 'app_index')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $user) {
return $this->redirectToRoute('app_security_login');
}
return $this->redirectToRoute($user->getDefaultRoute());
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Controller\Teamer;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\ProfileType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ProfileController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient
) {
}
#[Route('/teamer/profile', name: 'app_teamer_profile')]
#[IsGranted('ROLE_TEAMER')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->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(),
]);
}
}
+6 -5
View File
@@ -25,13 +25,14 @@ class Address
#[ORM\Column(type: 'string', nullable: true)] #[ORM\Column(type: 'string', nullable: true)]
protected ?string $country = null; protected ?string $country = null;
public function toArray(): array public function toPayload(): array
{ {
return [ return [
'street' => $this->getStreet(), 'strasse' => $this->getStreet(),
'post_code' => $this->getPostCode(), 'plz' => $this->getPostCode(),
'city' => $this->getCity(), 'ort' => $this->getCity(),
'country' => $this->getCountry(), 'ortsteil' => '',
'land' => $this->getCountry(),
]; ];
} }
+9
View File
@@ -19,6 +19,15 @@ class Communication
#[Assert\Email(mode: 'strict')] #[Assert\Email(mode: 'strict')]
protected ?string $email = null; 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 public static function fromApiResponse(ProfileResponse $profileResponse): static
{ {
$communication = $profileResponse->getCommunication(); $communication = $profileResponse->getCommunication();
+22
View File
@@ -117,6 +117,28 @@ class Teamer implements TimestampableEntityInterface
$this->dispositions = new ArrayCollection(); $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 public static function fromApiResponse(ProfileResponse $profileResponse): static
{ {
$instance = new static(); $instance = new static();
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Form;
use App\BusProNet\ApiClient;
use App\Form\ChoiceLoader\BpnCountryChoiceLoader;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BpnCountryType extends AbstractType
{
public function __construct(private readonly ApiClient $apiClient)
{}
public function getParent(): string
{
return ChoiceType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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']]
);
},
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Form\ChoiceLoader;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\CountriesResponse;
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
class BpnCountryChoiceLoader implements ChoiceLoaderInterface
{
public function __construct(private readonly ApiClient $apiClient, private readonly string $property)
{}
public function loadChoiceList(callable $value = null): ChoiceListInterface
{
try {
/** @var CountriesResponse $response */
$response = $this->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;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Form;
use App\Entity\Teamer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}
+4 -1
View File
@@ -48,7 +48,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$csrfToken = $request->request->get('_csrf_token', ''); $csrfToken = $request->request->get('_csrf_token', '');
return new SelfValidatingPassport( return new SelfValidatingPassport(
new UserBadge($email, function () use ($email, $password) { new UserBadge($email, function () use ($email, $password, $request) {
try { try {
$response = $this->apiClient->getProfile($email, $password); $response = $this->apiClient->getProfile($email, $password);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
@@ -59,6 +59,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return null; return null;
} }
// Store BPN password in session for later use
$request->getSession()->set('bpn_password', $password);
return $this->getOrCreateLocalUser($response, $email, $password); return $this->getOrCreateLocalUser($response, $email, $password);
}), }),
[new CsrfTokenBadge('authenticate', $csrfToken)] [new CsrfTokenBadge('authenticate', $csrfToken)]
+4
View File
@@ -4,6 +4,10 @@ module.exports = {
'./templates/**/*.twig', './templates/**/*.twig',
], ],
theme: { theme: {
container: {
center: true,
padding: '2rem',
},
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Lato', 'sans-serif'], sans: ['Lato', 'sans-serif'],
+23 -1
View File
@@ -3,7 +3,7 @@
{%- block form_widget_simple -%} {%- block form_widget_simple -%}
{%- set type = type|default('text') -%} {%- set type = type|default('text') -%}
{%- if type != 'hidden' -%} {%- 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 -%} {%- if errors|length -%}
{%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%}
{%- endif -%} {%- endif -%}
@@ -16,3 +16,25 @@
<input type="hidden" name="{{ form.vars.full_name }}" value="{{ form.vars.value }}"> <input type="hidden" name="{{ form.vars.full_name }}" value="{{ form.vars.value }}">
{%- endif -%} {%- endif -%}
{%- endblock form_widget_simple -%} {%- 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 -%}
<input type="hidden" name="{{ form.vars.full_name }}" value="{{ form.vars.value }}">
{%- endif -%}
{%- endblock choice_widget_collapsed -%}
{%- block birthday_widget -%}
<div class="grid grid-cols-3 gap-x-1">
{{ form_widget(form.children['day']) }}
{{ form_widget(form.children['month']) }}
{{ form_widget(form.children['year']) }}
</div>
{%- endblock -%}
+25
View File
@@ -0,0 +1,25 @@
{% extends 'base.html.twig' %}
{% block body %}
{{ form_start(form) }}
<div class="container">
<div class="grid grid-cols-2 gap-8">
<div class="flex flex-col space-y-4">
{{ form_row(form.academicTitle) }}
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.gender) }}
{{ form_row(form.dateOfBirth) }}
{{ form_row(form.nationality) }}
</div>
<div></div>
<div>
<button type="submit" class="btn">
Aktualisieren
</button>
</div>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}